Compare commits
300 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 346b32236a | |||
| 8f7f682a92 | |||
| 2ece7074f6 | |||
| c7ab2de25e | |||
| c9b0259993 | |||
| 1f2c3dbe2a | |||
| d247e44985 | |||
| d60dec9d62 | |||
| 4913cc7297 | |||
| 86360661e9 | |||
| 1d09193652 | |||
| 4ced6141ae | |||
| 530225d37c | |||
| e6ab77d123 | |||
| 4e1477c4be | |||
| 2b5b8363ca | |||
| 1e0f95f8bd | |||
| 6a20c54da7 | |||
| db6ae38568 | |||
| 1e43ea48aa | |||
| efe5e4ae50 | |||
| 31f1ce00d3 | |||
| d395aaf574 | |||
| d78ca8a9d5 | |||
| 19291d6be9 | |||
| 5409ba2b13 | |||
| 8bde9a213c | |||
| 315cb33e3f | |||
| 474843ffa6 | |||
| 766f7ff0d1 | |||
| f8b162ec5f | |||
| 8a8a9bc9e4 | |||
| 61f9c52455 | |||
| 43d65840ea | |||
| ad5a4b5000 | |||
| 73088b75ba | |||
| f23fadf52b | |||
| 9101033019 | |||
| 72bf4ed962 | |||
| 3b772ac239 | |||
| 8c1948d226 | |||
| 43f3865f69 | |||
| 7ef8b0eb36 | |||
| c6073fe9d2 | |||
| 81b9a66ef5 | |||
| 79be4eb525 | |||
| 04225ef890 | |||
| 7f78d92fef | |||
| bbfcde3d1b | |||
| 9f4e9845c0 | |||
| ce8b5dc86d | |||
| efb62fe41a | |||
| c26e9eacfa | |||
| aa49efb236 | |||
| 6b947b7593 | |||
| 3da7ea6408 | |||
| 79aabd6f43 | |||
| 40ac931c65 | |||
| aa51ba219b | |||
| 7623f29ffb | |||
| 3979b1f4e7 | |||
| caadd59441 | |||
| 30549f4863 | |||
| 070cd4d5a5 | |||
| 44ef62271e | |||
| 7440720b7b | |||
| 110844e597 | |||
| b4d08b0232 | |||
| f1180f6053 | |||
| 59bec31046 | |||
| 8a7bec8df2 | |||
| c881bbdad3 | |||
| f1b968bb88 | |||
| 3c222d4f0f | |||
| 1d10283fc9 | |||
| 886be83af5 | |||
| 82091e31a8 | |||
| b2c84e2064 | |||
| 680417a0a2 | |||
| e5e29aca49 | |||
| 486f3cbc44 | |||
| 152f87a254 | |||
| 349066bcfa | |||
| c353bceb97 | |||
| 64dcdc9d0d | |||
| 4ea2e3659d | |||
| 734bd9529a | |||
| 1ef9d66eed | |||
| 2169b24875 | |||
| f2a3399f27 | |||
| 5266e64be2 | |||
| 9a17916dd2 | |||
| a388a8bec9 | |||
| 51a98a0c63 | |||
| 6efe524974 | |||
| 88c56a83b7 | |||
| b4aa4c5fcb | |||
| 183e910afd | |||
| d80ee4157c | |||
| d8ab7734cb | |||
| de6a6a3b00 | |||
| 9503f6cb4f | |||
| 6b118307eb | |||
| 070e960c05 | |||
| 96ce3f4c53 | |||
| e49d049455 | |||
| e7f219f7c3 | |||
| d7d5baa41a | |||
| 61072f4c07 | |||
| 7070867393 | |||
| d472c41092 | |||
| a9e2dd3552 | |||
| 6553a8845b | |||
| 994b1cf3b7 | |||
| 6aea83bf17 | |||
| 8d51877afa | |||
| 597cfb9573 | |||
| 703cf1f88b | |||
| 5dc7d44111 | |||
| ee348643f8 | |||
| 05a598812b | |||
| 7515d9106f | |||
| 8c7affc933 | |||
| 2680a8c44a | |||
| 1021d61be3 | |||
| 7224afafd1 | |||
| 020f832eed | |||
| 7fe2790199 | |||
| 38c51ed95e | |||
| 37ccaa4fdc | |||
| 8816ee02ce | |||
| 6104f592eb | |||
| 0591b00ded | |||
| 0127d283a6 | |||
| 2757ef3b4f | |||
| ab55da280c | |||
| 4e076c36d2 | |||
| c6d62f84da | |||
| 8a0d82f49b | |||
| 0c74997cfe | |||
| fc72c5f6e9 | |||
| 51a399c775 | |||
| fc75eeb76d | |||
| 37134b8c18 | |||
| c6b804bf0a | |||
| c754984df8 | |||
| 906aab3b73 | |||
| e1aaf9f6fc | |||
| 6170306d9e | |||
| 04cd9ff472 | |||
| 1bf42a7feb | |||
| 56dd7d3fd3 | |||
| 8837031fd2 | |||
| a0cfbbc2d2 | |||
| 280a6ff2fa | |||
| 78e808bc54 | |||
| c1445976d7 | |||
| ff8aa2a4f5 | |||
| 398436ecb5 | |||
| 06a4a27880 | |||
| f34c733706 | |||
| 95efa5d029 | |||
| 9a036f1968 | |||
| ec6d4ad496 | |||
| d70b8e2363 | |||
| ee3c5af7a4 | |||
| b02cd978c3 | |||
| e956d7c30d | |||
| b8fc4e6642 | |||
| ab1843b1c3 | |||
| 88a973dc68 | |||
| 27c77af591 | |||
| e7587ca9f5 | |||
| 59b125d8e2 | |||
| b05de96569 | |||
| a5d64d1859 | |||
| 5bba2bbd92 | |||
| 986091ac56 | |||
| 47b1af8e92 | |||
| d567225bf7 | |||
| d2b6bba15c | |||
| b7396d58d2 | |||
| 351e76c00d | |||
| 2c2c4f3683 | |||
| fdf78353ad | |||
| 4cc433a1b8 | |||
| 321b4e3d0e | |||
| 2e156fc534 | |||
| cc52811522 | |||
| 9cab8c7bc7 | |||
| eeb7d9a1b2 | |||
| 6cf06d2380 | |||
| 401ad2e65d | |||
| 4814ec2363 | |||
| 98b9d612fa | |||
| 874873541d | |||
| ef9ac76f06 | |||
| ca9db195de | |||
| c1e16f2163 | |||
| 61d32fa00f | |||
| cddb3f8ccf | |||
| 87a938fe58 | |||
| 9157694412 | |||
| aa34314175 | |||
| c7fc386d0f | |||
| 6bd814e346 | |||
| aa25852091 | |||
| c2740cd282 | |||
| 23875bb3cc | |||
| ee1eab8408 | |||
| 2254ba7496 | |||
| 4866ad08b1 | |||
| 97ebc19313 | |||
| 946ac6f66a | |||
| 90ddee14c2 | |||
| f17f8ae8c8 | |||
| d713bfc5f9 | |||
| 27fe8c24ec | |||
| eef1e4e8c6 | |||
| a7a5905874 | |||
| 021537de56 | |||
| fdfd75790d | |||
| 3d1f8d9cf7 | |||
| eec37ab710 | |||
| 5f499ec1b0 | |||
| 2b5223097f | |||
| 1efbc289ba | |||
| 3c57c8b78b | |||
| 9c4500f9cb | |||
| 1e2c5a68cf | |||
| dc6991e6ef | |||
| cdf233378c | |||
| 23769e6ad4 | |||
| 9f8058223a | |||
| b483a34517 | |||
| a8fbca9ef5 | |||
| de8c47c81c | |||
| b11089896a | |||
| 16549709e2 | |||
| 68977b73be | |||
| 3da2bc93cb | |||
| d9632a3412 | |||
| 03d22c4d06 | |||
| 19242b4152 | |||
| ceaed9af66 | |||
| e9364fa70f | |||
| 2bec205a30 | |||
| cbd3436ff7 | |||
| 57ff236f2d | |||
| 6085859874 | |||
| d413fb84a5 | |||
| c22b047b8c | |||
| 090edf7ef6 | |||
| cbaebcf649 | |||
| 4a0d38384f | |||
| ea006b68c2 | |||
| 202533fbb1 | |||
| 0952aa8217 | |||
| 787e8844bc | |||
| fe98f966d6 | |||
| 79ad3b0715 | |||
| f728011b2a | |||
| 569876538a | |||
| d2b1c132d1 | |||
| 8926152fca | |||
| 2682e0268c | |||
| f13a63dc2f | |||
| 4a7f24348c | |||
| 0fdbef578f | |||
| 29a12bb102 | |||
| 270764ff0f | |||
| 0e6521e433 | |||
| e20d94d6ba | |||
| f4802ece4d | |||
| 9800e37cd6 | |||
| 84f30b07c4 | |||
| fba5e7c7be | |||
| 1e7bd0a540 | |||
| 0a0af4e02a | |||
| 3aa56dcfc3 | |||
| 7e3c701ea6 | |||
| e672bdde54 | |||
| c7c4cb45a7 | |||
| 6e4275a510 | |||
| 3ef60be623 | |||
| a3d01dd0a5 | |||
| 9bd5fc5c68 | |||
| b6e71e32f5 | |||
| 9ccaae04db | |||
| 9f90624aa6 | |||
| 62c1fb3836 | |||
| 569c20cf63 | |||
| f658b71079 | |||
| c2c983a01e | |||
| 8eb851793d | |||
| 8e5e815ac9 | |||
| 143a254b0c | |||
| 62d1bdc462 | |||
| 0b35ae3bf0 | |||
| b55300ff6f |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"fingerprint": "639c16d45210921c3c8ece071ef18bbe0c426ea2"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Skill Registry — workspace
|
||||
|
||||
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
||||
|
||||
Last updated: 2026-06-05
|
||||
|
||||
## Sources scanned
|
||||
|
||||
- .opencode/skills
|
||||
- .claude/skills
|
||||
|
||||
## Contract
|
||||
|
||||
**Delegator use only.** This registry is an index, not a summary. Any agent that launches subagents reads it to select relevant skills, then passes exact `SKILL.md` paths for the subagent to read before work.
|
||||
|
||||
`SKILL.md` remains the source of truth. Do not inject generated summaries or compact rules by default; pass paths so subagents load the full runtime contract and preserve author intent.
|
||||
|
||||
## Skills
|
||||
|
||||
| Skill | Trigger / description | Scope | Path |
|
||||
| --- | --- | --- | --- |
|
||||
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/workspace/.opencode/skills/openspec-apply-change/SKILL.md` |
|
||||
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/workspace/.opencode/skills/openspec-archive-change/SKILL.md` |
|
||||
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/workspace/.opencode/skills/openspec-explore/SKILL.md` |
|
||||
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/workspace/.opencode/skills/openspec-propose/SKILL.md` |
|
||||
| `sift-backlog` | Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open. | project | `/workspace/.claude/skills/sift-backlog/SKILL.md` |
|
||||
|
||||
## Loading protocol
|
||||
|
||||
1. Match task context and target files against the `Trigger / description` column.
|
||||
2. Pass only the matching `Path` values to the subagent under `## Skills to load before work`.
|
||||
3. Instruct the subagent to read those exact `SKILL.md` files before reading, writing, reviewing, testing, or creating artifacts.
|
||||
4. If no matching skill exists, proceed without project skill injection and report `skill_resolution: none`.
|
||||
@@ -0,0 +1,20 @@
|
||||
# .claude (index)
|
||||
dir: .claude
|
||||
|
||||
## role
|
||||
Configuration directory for Claude AI assistant integration and custom instructions.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
## children
|
||||
- .claude/skills
|
||||
index: .claude/skills/.pi-map.index.md
|
||||
map: .claude/skills/.pi-map.md
|
||||
## files
|
||||
## links
|
||||
index: .claude/.pi-map.index.md
|
||||
map: .claude/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,18 @@
|
||||
# .claude
|
||||
dir: .claude
|
||||
|
||||
index: .claude/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Configuration directory for Claude AI assistant integration and custom instructions.
|
||||
## files
|
||||
## arch
|
||||
Project-specific AI tooling configuration using convention-based file organization for assistant context and behavior customization.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,20 @@
|
||||
# .claude/skills (index)
|
||||
dir: .claude/skills
|
||||
|
||||
## role
|
||||
Contains reusable AI skill definitions and prompt templates that configure Claude's specialized capabilities for specific development tasks.
|
||||
## parent
|
||||
index: .claude/.pi-map.index.md
|
||||
map: .claude/.pi-map.md
|
||||
## children
|
||||
- .claude/skills/sift-backlog
|
||||
index: .claude/skills/sift-backlog/.pi-map.index.md
|
||||
map: .claude/skills/sift-backlog/.pi-map.md
|
||||
## files
|
||||
## links
|
||||
index: .claude/skills/.pi-map.index.md
|
||||
map: .claude/skills/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,18 @@
|
||||
# .claude/skills
|
||||
dir: .claude/skills
|
||||
|
||||
index: .claude/skills/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Contains reusable AI skill definitions and prompt templates that configure Claude's specialized capabilities for specific development tasks.
|
||||
## files
|
||||
## arch
|
||||
Modular skill-based architecture using declarative configuration files (likely YAML/JSON) to define context-specific behaviors, tool access patterns, and system prompts for different operational modes.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .claude/skills/sift-backlog (index)
|
||||
dir: .claude/skills/sift-backlog
|
||||
|
||||
## role
|
||||
Defines a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using a custom CLI tool.
|
||||
## parent
|
||||
index: .claude/skills/.pi-map.index.md
|
||||
map: .claude/skills/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- SKILL.md
|
||||
## links
|
||||
index: .claude/skills/sift-backlog/.pi-map.index.md
|
||||
map: .claude/skills/sift-backlog/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .claude/skills/sift-backlog
|
||||
dir: .claude/skills/sift-backlog
|
||||
|
||||
index: .claude/skills/sift-backlog/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using a custom CLI tool.
|
||||
## files
|
||||
- SKILL.md | Defines a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using a custom CLI tool. | dep: sf (custom CLI tool), task management system, plan management system
|
||||
## arch
|
||||
Documentation-driven skill definition using structured markdown with command specifications, workflow stages, and integration patterns for Claude CLI tooling.
|
||||
## tags
|
||||
skill, defines, workflow, triaging, organizing, activating, backlog, tasks
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+8
-1
@@ -17,6 +17,7 @@ __pycache__/
|
||||
*.so
|
||||
.python-version
|
||||
.venv/
|
||||
.venv-test/
|
||||
venv/
|
||||
env/
|
||||
.pytest_cache/
|
||||
@@ -49,5 +50,11 @@ apps/web/dist/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
/.stoneforge/.worktrees/
|
||||
# Local Pi runtime state
|
||||
# Pi / agent cache
|
||||
.pi/
|
||||
.atl/
|
||||
.sisyphus/
|
||||
.pi-lens/
|
||||
minerv3/
|
||||
.cache/
|
||||
openspec-audit-report.md
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# .opencode (index)
|
||||
dir: .opencode
|
||||
|
||||
## role
|
||||
Hidden directory for OpenCode IDE/editor configuration and workspace metadata
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
## children
|
||||
- .opencode/commands
|
||||
index: .opencode/commands/.pi-map.index.md
|
||||
map: .opencode/commands/.pi-map.md
|
||||
- .opencode/skills
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
## files
|
||||
## links
|
||||
index: .opencode/.pi-map.index.md
|
||||
map: .opencode/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,18 @@
|
||||
# .opencode
|
||||
dir: .opencode
|
||||
|
||||
index: .opencode/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Hidden directory for OpenCode IDE/editor configuration and workspace metadata
|
||||
## files
|
||||
## arch
|
||||
IDE-specific dot-directory pattern, no active code architecture; stores tool preferences and ephemeral state
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,22 @@
|
||||
# .opencode/commands (index)
|
||||
dir: .opencode/commands
|
||||
|
||||
## role
|
||||
Defines experimental workflow skills and AI assistant stances for an OpenSpec-based development system with structured change management.
|
||||
## parent
|
||||
index: .opencode/.pi-map.index.md
|
||||
map: .opencode/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- opsx-apply.md
|
||||
- opsx-archive.md
|
||||
- opsx-explore.md
|
||||
- opsx-propose.md
|
||||
## links
|
||||
index: .opencode/commands/.pi-map.index.md
|
||||
map: .opencode/commands/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,22 @@
|
||||
# .opencode/commands
|
||||
dir: .opencode/commands
|
||||
|
||||
index: .opencode/commands/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines experimental workflow skills and AI assistant stances for an OpenSpec-based development system with structured change management.
|
||||
## files
|
||||
- opsx-apply.md | Defines an experimental workflow skill for implementing tasks from an OpenSpec change through a structured, interactive process with CLI integration and progress tracking. | dep: openspec CLI, AskUserQuestion tool, filesystem (for reading context files)
|
||||
- opsx-archive.md | Defines a workflow for archiving completed changes in an experimental openspec-based development system | dep: openspec CLI, AskUserQuestion tool, Task tool, Skill tool, filesystem (mkdir, mv), JSON parsing
|
||||
- opsx-explore.md | Defines the "explore mode" stance for an AI assistant - a thinking/discovery mode for investigating problems and clarifying requirements without implementing code | dep: OpenSpec system
|
||||
- opsx-propose.md | Defines a workflow for proposing new changes in the openspec system by creating a change directory and generating all required artifacts (proposal.md, design.md, tasks.md) in dependency order | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool, JSON parsing
|
||||
## arch
|
||||
Markdown-based command definitions using a workflow pattern with interactive CLI integration, progress tracking, and dependency-ordered artifact generation across explore/propose/apply/archive lifecycle phases.
|
||||
## tags
|
||||
opsx, defines, workflow, openspec, openspec cli, askuserquestion tool, explore, experimental
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,29 @@
|
||||
# .opencode/skills (index)
|
||||
dir: .opencode/skills
|
||||
|
||||
## role
|
||||
Contains reusable AI skill modules that provide specialized capabilities for the OpenCode assistant.
|
||||
## parent
|
||||
index: .opencode/.pi-map.index.md
|
||||
map: .opencode/.pi-map.md
|
||||
## children
|
||||
- .opencode/skills/openspec-apply-change
|
||||
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-apply-change/.pi-map.md
|
||||
- .opencode/skills/openspec-archive-change
|
||||
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-archive-change/.pi-map.md
|
||||
- .opencode/skills/openspec-explore
|
||||
index: .opencode/skills/openspec-explore/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-explore/.pi-map.md
|
||||
- .opencode/skills/openspec-propose
|
||||
index: .opencode/skills/openspec-propose/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-propose/.pi-map.md
|
||||
## files
|
||||
## links
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,18 @@
|
||||
# .opencode/skills
|
||||
dir: .opencode/skills
|
||||
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Contains reusable AI skill modules that provide specialized capabilities for the OpenCode assistant.
|
||||
## files
|
||||
## arch
|
||||
Modular plugin-based architecture where each skill is a self-contained module with defined interfaces, enabling dynamic loading and composition of AI capabilities.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-apply-change (index)
|
||||
dir: .opencode/skills/openspec-apply-change
|
||||
|
||||
## role
|
||||
Defines an AI assistant skill that implements OpenSpec changes through a spec-driven workflow with structured planning, validation, and execution phases.
|
||||
## parent
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- SKILL.md
|
||||
## links
|
||||
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-apply-change/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-apply-change
|
||||
dir: .opencode/skills/openspec-apply-change
|
||||
|
||||
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines an AI assistant skill that implements OpenSpec changes through a spec-driven workflow with structured planning, validation, and execution phases.
|
||||
## files
|
||||
- SKILL.md | Defines an AI assistant skill for implementing tasks from an OpenSpec change using a spec-driven workflow | dep: openspec CLI, AskUserQuestion tool, filesystem access
|
||||
## arch
|
||||
Template-based skill definition using markdown documentation with structured workflow phases (planning, validation, execution) and integration points for external tools (OpenSpec CLI, OpenCode agent).
|
||||
## tags
|
||||
skill, defines, assistant, implementing, tasks, openspec, change, spec
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-archive-change (index)
|
||||
dir: .opencode/skills/openspec-archive-change
|
||||
|
||||
## role
|
||||
Provides a reusable automation skill for archiving completed experimental changes via the openspec CLI
|
||||
## parent
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- SKILL.md
|
||||
## links
|
||||
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-archive-change/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-archive-change
|
||||
dir: .opencode/skills/openspec-archive-change
|
||||
|
||||
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides a reusable automation skill for archiving completed experimental changes via the openspec CLI
|
||||
## files
|
||||
- SKILL.md | Defines a skill for archiving completed changes in an experimental workflow using the openspec CLI. | dep: openspec CLI, AskUserQuestion tool, Task tool, file system (mkdir, mv, read), JSON parsing
|
||||
## arch
|
||||
Skill-based modular automation pattern using markdown-defined CLI operations with structured metadata and command templates
|
||||
## tags
|
||||
skill, defines, archiving, completed, changes, experimental, workflow, openspec
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-explore (index)
|
||||
dir: .opencode/skills/openspec-explore
|
||||
|
||||
## role
|
||||
Defines a conversational AI skill/persona for "explore mode" that serves as a thinking partner for exploring ideas, investigating problems, and clarifying requirements without implementing code.
|
||||
## parent
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- SKILL.md
|
||||
## links
|
||||
index: .opencode/skills/openspec-explore/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-explore/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-explore
|
||||
dir: .opencode/skills/openspec-explore
|
||||
|
||||
index: .opencode/skills/openspec-explore/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines a conversational AI skill/persona for "explore mode" that serves as a thinking partner for exploring ideas, investigating problems, and clarifying requirements without implementing code.
|
||||
## files
|
||||
- SKILL.md | Defines a conversational AI skill/persona for "explore mode" - a thinking partner for exploring ideas, investigating problems, and clarifying requirements without implementing code. | dep: openspec CLI
|
||||
## arch
|
||||
Single-file skill definition using markdown-based persona specification with structured sections for description, usage guidelines, and behavioral constraints.
|
||||
## tags
|
||||
skill, defines, conversational, persona, explore, mode, thinking, partner
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-propose (index)
|
||||
dir: .opencode/skills/openspec-propose
|
||||
|
||||
## role
|
||||
Provides a structured workflow skill for proposing new changes using the openspec CLI with artifact generation in dependency order.
|
||||
## parent
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- SKILL.md
|
||||
## links
|
||||
index: .opencode/skills/openspec-propose/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-propose/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-propose
|
||||
dir: .opencode/skills/openspec-propose
|
||||
|
||||
index: .opencode/skills/openspec-propose/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides a structured workflow skill for proposing new changes using the openspec CLI with artifact generation in dependency order.
|
||||
## files
|
||||
- SKILL.md | Defines a structured workflow for proposing new changes using the openspec CLI, generating proposal, design, and task artifacts in dependency order. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool
|
||||
## arch
|
||||
Template-based skill definition using markdown documentation with sequential artifact generation (proposal → design → tasks) following dependency ordering.
|
||||
## tags
|
||||
skill, defines, structured, workflow, proposing, new, changes, openspec
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,82 @@
|
||||
# . (index)
|
||||
dir: .
|
||||
|
||||
## Project Map Protocol
|
||||
|
||||
1. Read this protocol and the root `.pi-map.index.md` first.
|
||||
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
|
||||
3. Load indexes before rich maps during task-start navigation.
|
||||
4. Read the local rich map and actual source before editing.
|
||||
5. Treat non-empty `## dirty` sections in either artifact as stale.
|
||||
6. If source and generated artifacts disagree, trust source.
|
||||
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||
8. After editing source, run `project_map_patch` for each changed file.
|
||||
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
|
||||
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Infrastructure and deployment configuration package for a self-hosted project management platform with OAuth2 authentication, providing containerized orchestration, environment templates, and development tooling.
|
||||
## parent
|
||||
-
|
||||
## children
|
||||
- .atl
|
||||
index: .atl/.pi-map.index.md
|
||||
map: .atl/.pi-map.md
|
||||
- .claude
|
||||
index: .claude/.pi-map.index.md
|
||||
map: .claude/.pi-map.md
|
||||
- .opencode
|
||||
index: .opencode/.pi-map.index.md
|
||||
map: .opencode/.pi-map.md
|
||||
- .pi
|
||||
index: .pi/.pi-map.index.md
|
||||
map: .pi/.pi-map.md
|
||||
- .sisyphus
|
||||
index: .sisyphus/.pi-map.index.md
|
||||
map: .sisyphus/.pi-map.md
|
||||
- .stoneforge
|
||||
index: .stoneforge/.pi-map.index.md
|
||||
map: .stoneforge/.pi-map.md
|
||||
- apps
|
||||
index: apps/.pi-map.index.md
|
||||
map: apps/.pi-map.md
|
||||
- docs
|
||||
index: docs/.pi-map.index.md
|
||||
map: docs/.pi-map.md
|
||||
- e2e
|
||||
index: e2e/.pi-map.index.md
|
||||
map: e2e/.pi-map.md
|
||||
- minerv3
|
||||
index: minerv3/.pi-map.index.md
|
||||
map: minerv3/.pi-map.md
|
||||
- openspec
|
||||
index: openspec/.pi-map.index.md
|
||||
map: openspec/.pi-map.md
|
||||
- scripts
|
||||
index: scripts/.pi-map.index.md
|
||||
map: scripts/.pi-map.md
|
||||
- tool-images
|
||||
index: tool-images/.pi-map.index.md
|
||||
map: tool-images/.pi-map.md
|
||||
- uploads
|
||||
index: uploads/.pi-map.index.md
|
||||
map: uploads/.pi-map.md
|
||||
## files
|
||||
- .env.example
|
||||
- .gitignore
|
||||
- AGENTS.md
|
||||
- CHANGELOG.md
|
||||
- Makefile
|
||||
- README.md
|
||||
- docker-compose.traefik.yml
|
||||
- docker-compose.yml
|
||||
- progress.md
|
||||
- swap-pane
|
||||
## links
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# .
|
||||
dir: .
|
||||
|
||||
index: ./.pi-map.index.md
|
||||
|
||||
## Project Map Protocol
|
||||
|
||||
1. Read this protocol and the root `.pi-map.index.md` first.
|
||||
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
|
||||
3. Load indexes before rich maps during task-start navigation.
|
||||
4. Read the local rich map and actual source before editing.
|
||||
5. Treat non-empty `## dirty` sections in either artifact as stale.
|
||||
6. If source and generated artifacts disagree, trust source.
|
||||
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||
8. After editing source, run `project_map_patch` for each changed file.
|
||||
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
|
||||
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Infrastructure and deployment configuration package for a self-hosted project management platform with OAuth2 authentication, providing containerized orchestration, environment templates, and development tooling.
|
||||
## files
|
||||
- .env.example | Provides a template of environment variables for configuring a Headquarter application with PostgreSQL, Redis, Authentik SSO, and Docker/Traefik deployment
|
||||
- .gitignore | Specifies files and directories for Git to ignore across a multi-language project with Python, Node, and custom tooling | dep: Git
|
||||
- AGENTS.md | Defines operational rules, workflows, and constraints for AI agents working within an OpenSpec-driven software development project. | dep: OpenSpec, superpowers, git, docker compose, conventional commits
|
||||
- CHANGELOG.md | Documents version history and notable changes for a Git-based project management web application
|
||||
- Makefile | Provides standard development commands for containerized web application lifecycle management via Docker Compose | dep: docker compose, alembic, pytest, ruff, mypy, playwright, npm, postgres, redis
|
||||
- README.md | A self-hosted platform for managing projects, git repositories, and development tools with OAuth2 authentication. | dep: FastAPI, SQLAlchemy, Pydantic, Alembic, python-jose, React, TypeScript, Vite, React Router, Docker, PostgreSQL, Traefik, Authentik, Git
|
||||
- docker-compose.traefik.yml | Deploys a multi-service web application (frontend, API, PostgreSQL, Redis) behind an existing Traefik reverse proxy with TLS termination and environment-configurable domains. | dep: docker, traefik, postgres, redis, authentik, docker-compose
|
||||
- docker-compose.yml | Defines a multi-service Docker Compose stack with PostgreSQL, Redis, API backend, and web frontend services for a "headquarter" application | dep: Docker, PostgreSQL, Redis, Vite, asyncpg, nginx
|
||||
- progress.md | Tracks completed and remaining tasks for a backend-frontend code refactoring project organized in 7 phases
|
||||
- swap-pane | Empty file with no functionality
|
||||
## arch
|
||||
Docker Compose-based microservices architecture with frontend/backend separation, PostgreSQL/Redis data layer, Traefik reverse proxy integration, and environment-driven configuration management following twelve-factor app principles.
|
||||
## tags
|
||||
docker, redis, git, application, postgresql, compose, traefik, project
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"sessionID": "ses_1da2608b1ffergOzow3NQt1mGr",
|
||||
"updatedAt": "2026-05-15T23:50:42.832Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-05-15T23:50:42.832Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# .stoneforge (index)
|
||||
dir: .stoneforge
|
||||
|
||||
## role
|
||||
Internal configuration and state tracking directory for the Stoneforge application
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
## children
|
||||
- .stoneforge/sync
|
||||
index: .stoneforge/sync/.pi-map.index.md
|
||||
map: .stoneforge/sync/.pi-map.md
|
||||
## files
|
||||
- .dashboard-opened
|
||||
- .gitignore
|
||||
- config.yaml
|
||||
## links
|
||||
index: .stoneforge/.pi-map.index.md
|
||||
map: .stoneforge/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,21 @@
|
||||
# .stoneforge
|
||||
dir: .stoneforge
|
||||
|
||||
index: .stoneforge/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Internal configuration and state tracking directory for the Stoneforge application
|
||||
## files
|
||||
- .dashboard-opened | Stores timestamp and identifier data for tracking when a dashboard was opened
|
||||
- .gitignore | Specifies files and patterns for Git to ignore in version control
|
||||
- config.yaml | Configuration file for the Stoneforge application defining database, sync, playbook, identity, merge, workflow, and agent settings.
|
||||
## arch
|
||||
Simple dot-directory pattern storing metadata (.dashboard-opened), version control exclusions (.gitignore), and hierarchical YAML configuration (config.yaml) with domain-separated settings
|
||||
## tags
|
||||
config, stores, timestamp, identifier, data, tracking, dashboard, was
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,20 @@
|
||||
# .stoneforge/sync (index)
|
||||
dir: .stoneforge/sync
|
||||
|
||||
## role
|
||||
Persists distributed task execution state by storing dependency graphs and ephemeral worker agent records for a collaborative workflow system.
|
||||
## parent
|
||||
index: .stoneforge/.pi-map.index.md
|
||||
map: .stoneforge/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- dependencies.jsonl
|
||||
- elements.jsonl
|
||||
## links
|
||||
index: .stoneforge/sync/.pi-map.index.md
|
||||
map: .stoneforge/sync/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,20 @@
|
||||
# .stoneforge/sync
|
||||
dir: .stoneforge/sync
|
||||
|
||||
index: .stoneforge/sync/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Persists distributed task execution state by storing dependency graphs and ephemeral worker agent records for a collaborative workflow system.
|
||||
## files
|
||||
- dependencies.jsonl | Stores a sequence of dependency relationships between entities in JSON Lines format, tracking parent-child, blocking, and reply relationships with timestamps and creators.
|
||||
- elements.jsonl | Stores JSONL records of ephemeral worker agents with their session history, worktree assignments, and lifecycle metadata for a distributed task execution system.
|
||||
## arch
|
||||
Event-sourced JSONL append-only logs with entity-relationship modeling (parent-child, blocking, reply) and session-based worker lifecycle tracking.
|
||||
## tags
|
||||
stores, relationships, dependencies, elements, sequence, dependency, entities, json
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
OpenSpec is the source of truth. Superpowers is the default workflow. Keep changes small, scoped, and verified.
|
||||
|
||||
## Communication
|
||||
|
||||
All agent output, code comments, commit messages, documentation, and artifacts must be in **English** unless the user explicitly requests another language.
|
||||
|
||||
## Priority order
|
||||
|
||||
1. Current user instruction
|
||||
@@ -71,6 +75,7 @@ Do not:
|
||||
* Introduce new dependencies without clear justification.
|
||||
* Treat existing code as more authoritative than OpenSpec for intended behavior.
|
||||
* Decide product behavior silently when the spec is unclear.
|
||||
* Run `docker compose` commands (build, up, down, etc.) without explicit user approval and proper isolation (e.g., feature branches, separate worktrees, or staged rollouts). Docker Compose operations are deployment-level changes that can affect running services, shared volumes, and network state. Always ask first.
|
||||
|
||||
If scope must change, propose an OpenSpec update first.
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# apps (index)
|
||||
dir: apps
|
||||
|
||||
## role
|
||||
Contains the main deployable application modules or entry points for the project.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
## children
|
||||
- apps/api
|
||||
index: apps/api/.pi-map.index.md
|
||||
map: apps/api/.pi-map.md
|
||||
- apps/web
|
||||
index: apps/web/.pi-map.index.md
|
||||
map: apps/web/.pi-map.md
|
||||
## files
|
||||
## links
|
||||
index: apps/.pi-map.index.md
|
||||
map: apps/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,18 @@
|
||||
# apps
|
||||
dir: apps
|
||||
|
||||
index: apps/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Contains the main deployable application modules or entry points for the project.
|
||||
## files
|
||||
## arch
|
||||
Modular monolith or microservices architecture with separate application boundaries, each potentially having its own configuration, dependencies, and lifecycle.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,42 @@
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Test artifacts
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Alembic cache
|
||||
alembic/versions/__pycache__/
|
||||
|
||||
# Pi lens cache
|
||||
.pi-lens/
|
||||
|
||||
# Documentation
|
||||
docs/
|
||||
*.md
|
||||
|
||||
# Scripts not needed in container
|
||||
scripts/
|
||||
+568
@@ -0,0 +1,568 @@
|
||||
{
|
||||
"version": "v2",
|
||||
"timestamp": 1779889907001,
|
||||
"ruleHash": "fd9b2b15f2ac8993",
|
||||
"queries": [
|
||||
{
|
||||
"id": "bare-except",
|
||||
"name": "Bare Except Clause",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Bare 'except:' clause — catches SystemExit, KeyboardInterrupt",
|
||||
"query": " (except_clause\n \"except\") @CLAUSE",
|
||||
"metavars": [
|
||||
"CLAUSE"
|
||||
],
|
||||
"post_filter": "bare_except_only",
|
||||
"defect_class": "silent-error",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/bare-except.yml"
|
||||
},
|
||||
{
|
||||
"id": "eval-exec",
|
||||
"name": "Eval/Exec Usage",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "{{FUNC}}() detected — security risk, code injection vulnerability",
|
||||
"query": " (call\n function: (identifier) @FUNC\n (#match? @FUNC \"^(eval|exec)$\")\n arguments: (argument_list) @ARGS)",
|
||||
"metavars": [
|
||||
"FUNC",
|
||||
"ARGS"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/eval-exec.yml"
|
||||
},
|
||||
{
|
||||
"id": "exit-signature-check",
|
||||
"name": "__exit__ Missing Parameters",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "__exit__ should accept type, value, and traceback arguments",
|
||||
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__exit__\")\n parameters: (parameters\n (_) @SELF\n . (_) @PARAM1?\n . (_) @PARAM2?\n . (_) @PARAM3?))",
|
||||
"metavars": [
|
||||
"NAME",
|
||||
"SELF",
|
||||
"PARAM1",
|
||||
"PARAM2",
|
||||
"PARAM3"
|
||||
],
|
||||
"post_filter": "exit_params_insufficient",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/exit-signature-check.yml"
|
||||
},
|
||||
{
|
||||
"id": "in-operator-unsupported",
|
||||
"name": "In and Not In Operators Should Be Used on Valid Objects",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "'in' operator used on object that may not support containment",
|
||||
"query": " (comparison_operator\n (identifier) @OBJ\n \"in\"\n (identifier) @TARGET)\n (comparison_operator\n (identifier) @OBJ\n \"not\"\n \"in\"\n (identifier) @TARGET)",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"TARGET"
|
||||
],
|
||||
"post_filter": "check_in_operator_types",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/in-operator-unsupported.yml"
|
||||
},
|
||||
{
|
||||
"id": "is-vs-equals",
|
||||
"name": "Is vs Equals for Literals",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Using 'is' with literal — use '==' for value comparison",
|
||||
"query": " (comparison_operator\n (identifier)\n (\"is\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is\")\n (integer) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (integer) @LITERAL)",
|
||||
"metavars": [
|
||||
"LITERAL"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/is-vs-equals.yml"
|
||||
},
|
||||
{
|
||||
"id": "iter-return-iterator",
|
||||
"name": "__iter__ Should Return Iterator",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "__iter__ should return an iterator (object with __next__ method)",
|
||||
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__iter__\")\n body: (block\n (return_statement) @RETURN))",
|
||||
"metavars": [
|
||||
"NAME",
|
||||
"RETURN"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/iter-return-iterator.yml"
|
||||
},
|
||||
{
|
||||
"id": "mutable-default-arg",
|
||||
"name": "Mutable Default Argument",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Mutable default argument — list/dict/set as default value",
|
||||
"query": " (function_definition\n (parameters\n (default_parameter\n (identifier) @PARAM\n [(list) (dictionary) (set)] @MUTABLE)))",
|
||||
"metavars": [
|
||||
"PARAM",
|
||||
"MUTABLE"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/mutable-default-arg.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-super-torchscript",
|
||||
"name": "super Should Not Be Used in TorchScript Methods",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "super() calls should not be used in TorchScript methods",
|
||||
"query": " (function_definition\n (decorator\n (call\n function: (identifier) @DEC (#match? @DEC \"^(torch\\.jit\\.script|jit\\.script)$\")))\n body: (block\n (call\n function: (identifier) @FUNC (#eq? @FUNC \"super\")) @CALL))",
|
||||
"metavars": [
|
||||
"DEC",
|
||||
"FUNC",
|
||||
"CALL"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/no-super-torchscript.yml"
|
||||
},
|
||||
{
|
||||
"id": "notimplemented-boolean-context",
|
||||
"name": "NotImplemented in Boolean Context",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "NotImplemented should not be used in boolean contexts",
|
||||
"query": " (if_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (while_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (binary_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\")\n (\"and\" | \"or\"))\n (boolean_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (unary_operator\n operator: (\"not\")\n argument: (identifier) @COND (#eq? @COND \"NotImplemented\"))",
|
||||
"metavars": [
|
||||
"COND"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-assert-production",
|
||||
"name": "Assert in Production Code",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "assert statement stripped by Python -O flag — use explicit checks with exceptions in production code",
|
||||
"query": " (assert_statement) @ASSERT",
|
||||
"metavars": [
|
||||
"ASSERT"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-assert-production.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-command-injection",
|
||||
"name": "Command Injection Sink",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "Potential command injection sink — avoid shell execution with dynamic input",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"os\")\n (#match? @FN \"^(system|popen)$\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true)))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"ARGS",
|
||||
"KW"
|
||||
],
|
||||
"post_filter": "py_command_injection_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-command-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-cross-language-method",
|
||||
"name": "Cross-Language Method Leakage",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "'{METHOD}' is not a Python method — likely a {LANG} idiom leaking in",
|
||||
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @METHOD)\n (#match? @METHOD \"^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$\"))",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"METHOD"
|
||||
],
|
||||
"post_filter": "match_captures",
|
||||
"post_filter_params": {
|
||||
"METHOD": "^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$"
|
||||
},
|
||||
"defect_class": "hallucination",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-debugger",
|
||||
"name": "Debugger Statement",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Debugger call '{{FUNC}}' — remove before committing",
|
||||
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"breakpoint\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n (#eq? @MOD \"pdb\")\n (#match? @FUNC \"^(set_trace|post_mortem|pm|run|runcall)$\"))",
|
||||
"metavars": [
|
||||
"FUNC",
|
||||
"MOD"
|
||||
],
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-debugger.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-empty-except",
|
||||
"name": "Empty Except Block",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Except block only contains 'pass' — handle or re-raise the exception",
|
||||
"query": " (try_statement\n (except_clause\n body: (block) @BODY))",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"post_filter": "python_empty_except",
|
||||
"defect_class": "silent-error",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-empty-except.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-hallucinated-import",
|
||||
"name": "Hallucinated Import",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Hallucinated import — '{NAME}' does not exist in '{MODULE}'",
|
||||
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n name: (dotted_name) @NAME)",
|
||||
"metavars": [
|
||||
"MODULE",
|
||||
"NAME"
|
||||
],
|
||||
"post_filter": "match_captures",
|
||||
"post_filter_params": {
|
||||
"MODULE": "^(requests|flask|django|typing|collections|asyncio|json|unittest|pytest|urllib|sqlalchemy)$",
|
||||
"NAME": "^(JSONResponse|HTMLResponse|RedirectResponse|StreamingResponse|Depends|Query|Path|Body|Header|Cookie|Form|File|UploadFile|FastAPI|APIRouter|HTTPException|BackgroundTasks|dataclass|fields|BaseModel|Field|validator|aiohttp|parse|stringify|fixture|TestCase|get|post|put|delete|Model|Session|Column|Integer|String)$"
|
||||
},
|
||||
"defect_class": "hallucination",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-hardcoded-secrets",
|
||||
"name": "Hardcoded Secret",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Hardcoded {{VARNAME}} — use environment variables or a secrets manager",
|
||||
"query": " (assignment\n left: (identifier) @VARNAME\n right: (string) @VALUE)",
|
||||
"metavars": [
|
||||
"VARNAME",
|
||||
"VALUE"
|
||||
],
|
||||
"post_filter": "check_secret_pattern",
|
||||
"defect_class": "secrets",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hardcoded-secrets.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-insecure-deserialization",
|
||||
"name": "Insecure Deserialization",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "Potential insecure deserialization sink — avoid unsafe loaders",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list (_) @DATA)\n (#match? @MOD \"^(pickle|yaml)$\")\n (#match? @FN \"^(load|loads|unsafe_load)$\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"DATA"
|
||||
],
|
||||
"post_filter": "py_insecure_deserialization_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-deserialization.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-insecure-random",
|
||||
"name": "Insecure Randomness",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Insecure randomness source detected — use secrets or os.urandom for security-sensitive values",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"random\")\n (#match? @FN \"^(random|randint|randrange|choice|choices)$\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-random.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-mutable-class-attr",
|
||||
"name": "Mutable Class Attribute",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Class attribute '{{VARNAME}}' is mutable — shared across all instances",
|
||||
"query": " (class_definition\n body: (block\n (expression_statement\n (assignment\n left: (identifier) @VARNAME\n right: [\n (list) @VALUE\n (dictionary) @VALUE\n (set) @VALUE\n ]))))",
|
||||
"metavars": [
|
||||
"VARNAME",
|
||||
"VALUE"
|
||||
],
|
||||
"post_filter": "not_in_function",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-mutable-class-attr.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-path-traversal",
|
||||
"name": "Path Traversal Risk",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Potential path traversal sink — sanitize and constrain file paths",
|
||||
"query": " [\n (call\n function: (identifier) @FN\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n ]\n (#match? @FN \"^(open|read_text|read_bytes|write_text|write_bytes|remove|unlink|rmdir)$\")",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"PATH"
|
||||
],
|
||||
"post_filter": "py_path_traversal_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-path-traversal.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-print-statement",
|
||||
"name": "Print Statement in Production",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "print() — remove debug output before committing",
|
||||
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"print\")\n arguments: (argument_list) @ARGS)",
|
||||
"metavars": [
|
||||
"FUNC",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "not_in_test_block",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-print-statement.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-raise-string",
|
||||
"name": "Raise String Instead of Exception",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "raise with string literal — Python 3 requires exception instances",
|
||||
"query": " (raise_statement\n (string) @VALUE)",
|
||||
"metavars": [
|
||||
"VALUE"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-raise-string.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-sleep-in-test",
|
||||
"name": "time.sleep in Test",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "time.sleep() in test — use synchronisation primitives or polling helpers instead of fixed sleeps",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n (#eq? @MOD \"time\")\n (#eq? @FN \"sleep\")) @CALL",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"CALL"
|
||||
],
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sleep-in-test.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-sql-injection",
|
||||
"name": "SQL Injection Risk",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "Potential SQL injection sink — use parameterized queries",
|
||||
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(binary_operator) (identifier) (call)] @SQL\n (_)*))",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"SQL"
|
||||
],
|
||||
"post_filter": "py_sql_injection_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-ssrf",
|
||||
"name": "SSRF Risk",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Potential SSRF sink — validate/allowlist outbound URLs",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (subscript) (call)] @URL)\n (#eq? @MOD \"requests\")\n (#match? @FN \"^(get|post|put|patch|delete|request|head|options)$\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"URL"
|
||||
],
|
||||
"post_filter": "py_ssrf_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-ssrf.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-subprocess-shell",
|
||||
"name": "subprocess with shell=True",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "subprocess called with shell=True — command injection risk if any argument is user-controlled",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true) @VAL))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"KW"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-subprocess-shell.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-thread-global-write",
|
||||
"name": "Threaded Shared State Risk",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Thread creation detected — ensure shared state mutations are synchronized",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS)\n (#eq? @MOD \"threading\")\n (#eq? @FN \"Thread\")",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-thread-global-write.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-unsafe-regex",
|
||||
"name": "Unsafe Dynamic Regex",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "re.{{FUNC}}() with variable pattern — ReDoS risk if pattern is user-controlled",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n arguments: (argument_list\n (identifier) @PATTERN)\n (#eq? @MOD \"re\")\n (#match? @FUNC \"^(compile|match|search|fullmatch|findall|finditer|sub|subn|split)$\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FUNC",
|
||||
"PATTERN"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-unsafe-regex.yml"
|
||||
},
|
||||
{
|
||||
"id": "python-weak-hash",
|
||||
"name": "Weak Hash Primitive",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "Weak hash primitive detected (MD5/SHA1) — use SHA-256+ for security-sensitive contexts",
|
||||
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"hashlib\")\n (#match? @FN \"^(md5|sha1)$\"))",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-weak-hash.yml"
|
||||
},
|
||||
{
|
||||
"id": "return-in-generator",
|
||||
"name": "Return with Value in Generator",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "'return' with a value should not be used in a generator function",
|
||||
"query": " (function_definition\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN)) @FUNCTION",
|
||||
"metavars": [
|
||||
"FUNCTION",
|
||||
"RETURN",
|
||||
"RETURN_VAL"
|
||||
],
|
||||
"post_filter": "is_generator_with_valued_return",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-generator.yml"
|
||||
},
|
||||
{
|
||||
"id": "return-in-init",
|
||||
"name": "Return Value in __init__",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "__init__ should not return a value — it must always return None",
|
||||
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__init__\")\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN))",
|
||||
"metavars": [
|
||||
"NAME",
|
||||
"RETURN",
|
||||
"RETURN_VAL"
|
||||
],
|
||||
"post_filter": "has_return_value",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-init.yml"
|
||||
},
|
||||
{
|
||||
"id": "send-file-mimetype",
|
||||
"name": "send_file Should Specify Mimetype or Download Name",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "send_file should specify 'mimetype' or 'download_name' when used with file-like objects",
|
||||
"query": " (call\n function: (identifier) @FUNC (#eq? @FUNC \"send_file\")\n arguments: (argument_list\n (_) @FIRST_ARG\n (keyword_argument)? @KW))",
|
||||
"metavars": [
|
||||
"FUNC",
|
||||
"FIRST_ARG",
|
||||
"KW"
|
||||
],
|
||||
"post_filter": "missing_mimetype_and_download_name",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/send-file-mimetype.yml"
|
||||
},
|
||||
{
|
||||
"id": "unreachable-except",
|
||||
"name": "Unreachable Except Clause",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Unreachable except clause — earlier except catches all",
|
||||
"query": " (try_statement\n (except_clause\n \"except\") @GENERAL\n (except_clause\n \"except\"\n (identifier) @SPECIFIC))",
|
||||
"metavars": [
|
||||
"GENERAL",
|
||||
"SPECIFIC"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/unreachable-except.yml"
|
||||
},
|
||||
{
|
||||
"id": "wildcard-import",
|
||||
"name": "Wildcard Import",
|
||||
"severity": "warning",
|
||||
"language": "python",
|
||||
"message": "Wildcard import — pollutes namespace, hard to track origin",
|
||||
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n (wildcard_import) @WILDCARD)",
|
||||
"metavars": [
|
||||
"MODULE",
|
||||
"WILDCARD"
|
||||
],
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "warning",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/wildcard-import.yml"
|
||||
},
|
||||
{
|
||||
"id": "yield-return-outside-function",
|
||||
"name": "Yield/Return Outside Function",
|
||||
"severity": "error",
|
||||
"language": "python",
|
||||
"message": "{{STATEMENT}} used outside function — syntax error",
|
||||
"query": " (module\n (expression_statement\n (yield) @STATEMENT))\n (module\n (expression_statement\n (yield_expression) @STATEMENT))\n (module\n (return_statement) @STATEMENT)",
|
||||
"metavars": [
|
||||
"STATEMENT"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/yield-return-outside-function.yml"
|
||||
}
|
||||
]
|
||||
}
|
||||
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,45 @@
|
||||
# apps/api (index)
|
||||
dir: apps/api
|
||||
|
||||
## role
|
||||
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances.
|
||||
## parent
|
||||
index: apps/.pi-map.index.md
|
||||
map: apps/.pi-map.md
|
||||
## children
|
||||
- apps/api/.pi-lens
|
||||
index: apps/api/.pi-lens/.pi-map.index.md
|
||||
map: apps/api/.pi-lens/.pi-map.md
|
||||
- apps/api/.pytest_cache
|
||||
index: apps/api/.pytest_cache/.pi-map.index.md
|
||||
map: apps/api/.pytest_cache/.pi-map.md
|
||||
- apps/api/.venv-test
|
||||
index: apps/api/.venv-test/.pi-map.index.md
|
||||
map: apps/api/.venv-test/.pi-map.md
|
||||
- apps/api/alembic
|
||||
index: apps/api/alembic/.pi-map.index.md
|
||||
map: apps/api/alembic/.pi-map.md
|
||||
- apps/api/src
|
||||
index: apps/api/src/.pi-map.index.md
|
||||
map: apps/api/src/.pi-map.md
|
||||
- apps/api/tests
|
||||
index: apps/api/tests/.pi-map.index.md
|
||||
map: apps/api/tests/.pi-map.md
|
||||
- apps/api/uploads
|
||||
index: apps/api/uploads/.pi-map.index.md
|
||||
map: apps/api/uploads/.pi-map.md
|
||||
## files
|
||||
- .dockerignore
|
||||
- Dockerfile
|
||||
- README.md
|
||||
- alembic.ini
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- wait-for-db.sh
|
||||
## links
|
||||
index: apps/api/.pi-map.index.md
|
||||
map: apps/api/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,25 @@
|
||||
# apps/api
|
||||
dir: apps/api
|
||||
|
||||
index: apps/api/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances.
|
||||
## files
|
||||
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and avoid copying unnecessary files into containers. | dep: Docker
|
||||
- Dockerfile | Multi-stage Docker build for a Python application with Docker socket access, Cloudflare tunneling, and database dependency waiting | dep: python:3.11-slim, gcc, libpq-dev, docker-ce-cli, docker-compose-plugin, cloudflared, uvicorn, pyproject.toml dependencies
|
||||
- README.md | Documentation for a self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances. | dep: FastAPI, SQLAlchemy, PostgreSQL, asyncpg, Alembic, Docker, Docker Compose, Authentik, uvicorn, pytest, ruff, mypy
|
||||
- alembic.ini | Configuration file for Alembic database migration tool connecting to a PostgreSQL database with async driver | dep: alembic, sqlalchemy, asyncpg, PostgreSQL
|
||||
- pyproject.toml | Defines Python project metadata, dependencies, and tool configurations for a FastAPI-based backend API called "headquarter-api" | dep: fastapi, uvicorn, sqlalchemy, asyncpg, alembic, pydantic, pydantic-settings, python-multipart, httpx, structlog, cryptography, pytest, pytest-asyncio, mypy, ruff, aiosqlite
|
||||
- uv.lock | Lock file for the uv Python package manager that pins exact dependency versions and their artifact hashes for reproducible installations | dep: uv, Python 3.11+, aiosqlite, alembic, annotated-doc, annotated-types, anyio, ast-serialize, asyncpg, and many other PyPI packages
|
||||
- wait-for-db.sh | Wait for a PostgreSQL database to become available before executing a command, with configurable retry logic. | dep: nc (netcat), sh (POSIX shell), sleep
|
||||
## arch
|
||||
Async Python/FastAPI with PostgreSQL (Alembic migrations), multi-stage Docker deployment with Cloudflare tunneling, uv package management, and containerized service orchestration.
|
||||
## tags
|
||||
docker, alembic, python, database, fastapi, postgresql, asyncpg, uvicorn
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+2
-2
@@ -50,8 +50,8 @@ ENV PATH=/root/.local/bin:$PATH
|
||||
# Copy application code
|
||||
COPY --chown=appuser:appgroup . .
|
||||
|
||||
# Create directories for repo and instance storage
|
||||
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
|
||||
# Create directories for repo, instance, and workspace storage
|
||||
RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data
|
||||
|
||||
# Copy wait-for-db script
|
||||
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# apps/api/alembic (index)
|
||||
dir: apps/api/alembic
|
||||
|
||||
## role
|
||||
Database migration infrastructure for the API application, providing version-controlled schema evolution with async SQLAlchemy support.
|
||||
## parent
|
||||
index: apps/api/.pi-map.index.md
|
||||
map: apps/api/.pi-map.md
|
||||
## children
|
||||
- apps/api/alembic/versions
|
||||
index: apps/api/alembic/versions/.pi-map.index.md
|
||||
map: apps/api/alembic/versions/.pi-map.md
|
||||
## files
|
||||
- env.py
|
||||
- script.py.mako
|
||||
## links
|
||||
index: apps/api/alembic/.pi-map.index.md
|
||||
map: apps/api/alembic/.pi-map.md
|
||||
## workflows
|
||||
- change alembic behavior
|
||||
read: env.py, script.py.mako
|
||||
- explore alembic subdirectories
|
||||
index: apps/api/alembic/versions/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,30 @@
|
||||
# apps/api/alembic
|
||||
dir: apps/api/alembic
|
||||
|
||||
index: apps/api/alembic/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Database migration infrastructure for the API application, providing version-controlled schema evolution with async SQLAlchemy support.
|
||||
## files
|
||||
- env.py | Configures Alembic database migration environment with async SQLAlchemy support for a project. | exp: func:run_migrations_offline() → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:do_run_migrations(connection: Connection) → None, call:context.configure, call:context.begin_transaction, call:context.run_migrations, func:run_async_migrations() → None, call:async_engine_from_config, call:config.get_section, call:connectable.connect, call:connection.run_sync, call:connectable.dispose, func:run_migrations_online() → None, call:asyncio.run, call:run_async_migrations | dep: logging.config, alembic, sqlalchemy, sqlalchemy.engine, sqlalchemy.ext.asyncio, src.config, src.models, asyncio
|
||||
- script.py.mako | Alembic database migration script template that generates upgrade/downgrade functions for SQLAlchemy schema migrations | dep: alembic, sqlalchemy
|
||||
## arch
|
||||
Alembic migration framework with Mako templating for generating revision scripts, async SQLAlchemy engine configuration, and autogenerate capabilities for schema change tracking.
|
||||
## tags
|
||||
migrations, run, sqlalchemy, async, alembic, call:context.configure, call:context.begin, transaction
|
||||
## symbols
|
||||
- run_migrations_offline
|
||||
- do_run_migrations
|
||||
- run_async_migrations
|
||||
- run_migrations_online
|
||||
- call:context.configure
|
||||
- call:context.begin_transaction
|
||||
- call:context.run_migrations
|
||||
- call:async_engine_from_config
|
||||
## workflows
|
||||
- change alembic behavior
|
||||
read: env.py, script.py.mako
|
||||
- explore alembic subdirectories
|
||||
index: apps/api/alembic/versions/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,69 @@
|
||||
# apps/api/alembic/versions (index)
|
||||
dir: apps/api/alembic/versions
|
||||
|
||||
## role
|
||||
Manages incremental database schema evolution for the API application using Alembic migrations, tracking all table creations, column additions, relationship changes, and data transformations over the project's lifecycle.
|
||||
## parent
|
||||
index: apps/api/alembic/.pi-map.index.md
|
||||
map: apps/api/alembic/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- 0001_initial_schema.py
|
||||
- 0002_refresh_tokens.py
|
||||
- 0003_user_configs.py
|
||||
- 0004_tool_types.py
|
||||
- 0005_ssh_keys_timestamps.py
|
||||
- 0006_tool_instances.py
|
||||
- 0007_instance_container_name.py
|
||||
- 0008_tool_type_category.py
|
||||
- 0009_tool_configs.py
|
||||
- 0010_tool_type_default_port.py
|
||||
- 0011_tool_instance_tunnel_fields.py
|
||||
- 0012_default_port_req.py
|
||||
- 0013_add_config_profiles.py
|
||||
- 0013_add_probe_result.py
|
||||
- 0014_add_profile_resolver_fields.py
|
||||
- 0014_merge_heads.py
|
||||
- 0015_single_interface.py
|
||||
- 069d3da4dc9b_add_ssh_key_id_to_config_profiles.py
|
||||
- 20260527160017_add_pi_agent_tool_type.py
|
||||
- 2026_05_22_add_clone_mode.py
|
||||
- 2026_05_23_remove_is_builtin.py
|
||||
- 2026_05_24_220141_add_startup_command.py
|
||||
- 2026_05_24_add_config_profiles.py
|
||||
- 2026_05_26_add_git_mounts.py
|
||||
- 2026_05_27_external_repos.py
|
||||
- 2026_05_28_add_monitoring_tables.py
|
||||
- 2026_05_28_add_terminal_sessions_table.py
|
||||
- 2026_05_28_add_tool_definition_manifests.py
|
||||
- 2026_05_28_drop_tool_configs_and_config_folders.py
|
||||
- 2026_05_29_add_notifications_table.py
|
||||
- 2026_05_29_add_ssh_key_ids_to_tool_instances.py
|
||||
- 2026_05_29_drop_ssh_key_id_from_config_profiles.py
|
||||
- 2026_05_29_fix_code_server_bind_addr.py
|
||||
- 2026_05_29_fix_code_server_bind_addr_port.py
|
||||
- 2026_05_29_fix_web_tool_bind_address.py
|
||||
- 2026_05_29_remove_lsio_command_override.py
|
||||
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py
|
||||
- 2026_06_01_add_workspaces.py
|
||||
- 2026_06_13_make_clone_mode_nullable.py
|
||||
- 398082499c30_add_tool_config_fields.py
|
||||
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py
|
||||
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py
|
||||
- 8c6d1dbd4798_remove_pi_config_and_state_mounts_from_.py
|
||||
- 8ed7dd80973d_create_config_folders_table.py
|
||||
- af8512103d67_add_tool_type_fields.py
|
||||
- f3d2dc90ba3a_merge_single_interface_and_clone_mode.py
|
||||
## links
|
||||
index: apps/api/alembic/versions/.pi-map.index.md
|
||||
map: apps/api/alembic/versions/.pi-map.md
|
||||
## workflows
|
||||
- change versions behavior
|
||||
read: 0001_initial_schema.py, 0002_refresh_tokens.py, 0003_user_configs.py
|
||||
- change versions CLI
|
||||
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
|
||||
- change versions config
|
||||
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,76 @@
|
||||
# apps/api/alembic/versions
|
||||
dir: apps/api/alembic/versions
|
||||
|
||||
index: apps/api/alembic/versions/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Manages incremental database schema evolution for the API application using Alembic migrations, tracking all table creations, column additions, relationship changes, and data transformations over the project's lifecycle.
|
||||
## files
|
||||
- 0001_initial_schema.py | Defines the initial database schema migration creating five tables (users, ssh_keys, projects, git_repositories, user_configs) with relationships, indexes, and constraints using Alembic. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.String, call:postgresql.UUID, call:sa.DateTime, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:op.create_index, call:op.f, call:sa.Text, call:sa.ForeignKeyConstraint, call:sa.Boolean, call:postgresql.JSONB, func:downgrade() → None, call:op.drop_table, call:op.drop_index, call:op.f | dep: alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
|
||||
- 0002_refresh_tokens.py | Alembic database migration that creates a refresh_tokens table with indexes for user authentication token management | exp: func:upgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.String, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:inspector.get_indexes, call:op.f, call:op.create_index, func:downgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:inspector.get_indexes, call:op.f, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||
- 0003_user_configs.py | Alembic database migration that creates a user_configs table with JSON configuration storage linked to users | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.JSON, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, func:downgrade() → None, call:op.drop_table | dep: typing, alembic, sqlalchemy
|
||||
- 0004_tool_types.py | Alembic database migration that creates a tool_types table with metadata, templates, and versioning columns for a tool management system. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.ForeignKey, call:sa.DateTime, call:sa.text, func:downgrade() → None, call:op.drop_table | dep: typing, alembic, sqlalchemy
|
||||
- 0005_ssh_keys_timestamps.py | Alembic database migration that adds created_at and updated_at timestamp columns to the ssh_keys table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.DateTime, call:sa.text, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||
- 0006_tool_instances.py | Alembic database migration that creates a tool_instances table with columns for tracking deployed tool instances, their status, container info, and foreign key relationships to tool_types, git_repositories, projects, and users. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.String, call:sa.Integer, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||
- 0007_instance_container_name.py | Alembic database migration that adds a nullable container_name column to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||
- 0008_tool_type_category.py | Alembic database migration that adds `category` and `interfaces` columns to the `tool_types` table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||
- 0009_tool_configs.py | Alembic database migration that creates a tool_configs table with UUID keys, foreign key relationships, and indexes for storing user/project tool configuration settings. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.String, call:sa.Text, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
|
||||
- 0010_tool_type_default_port.py | Alembic database migration that adds a nullable default_port column to the tool_types table. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Integer, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||
- 0011_tool_instance_tunnel_fields.py | Alembic database migration that adds tunnel-related fields (public_url and tunnel_id) to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||
- 0012_default_port_req.py | Alembic database migration that populates null default_port values for existing tool types and then makes the column non-nullable | exp: func:upgrade() → None, call:op.execute, call:op.alter_column, call:sa.Integer, func:downgrade() → None, call:op.alter_column, call:sa.Integer | dep: typing, alembic, sqlalchemy
|
||||
- 0013_add_config_profiles.py | Alembic database migration that adds config profiles, includes, mounts tables and links tool instances to profiles with defensive idempotent checks | exp: func:_table_exists(table_name: str) → bool, call:sa.inspect(op.get_bind()).has_table, call:op.get_bind, func:_column_exists(table_name: str, column_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_columns, call:op.get_bind, func:_index_exists(table_name: str, index_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_indexes, call:op.get_bind, func:_foreign_key_exists(table_name: str, constrained_columns: list[str], referred_table: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, func:upgrade() → None, call:_table_exists, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.String, call:sa.Text, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:_index_exists, call:op.create_index, call:sa.Integer, call:_column_exists, call:op.add_column, call:_foreign_key_exists, call:op.create_foreign_key, func:downgrade() → None, call:op.drop_index, call:op.drop_constraint, call:op.drop_column, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||
- 0013_add_probe_result.py | Alembic database migration that adds a JSON probe_result column to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||
- 0014_add_profile_resolver_fields.py | Alembic database migration that adds profile resolver fields (project_id, tool_type_id, environment_variables, etc.) to config_profiles table and restructures config_mounts table (renaming mount_path to target_path, adding mode/files, removing content/source_profile_id). | exp: func:_table_exists(table_name: str) → bool, call:sa.inspect(op.get_bind()).has_table, call:op.get_bind, func:_column_exists(table_name: str, column_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_columns, call:op.get_bind, func:_index_exists(table_name: str, index_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_indexes, call:op.get_bind, func:_foreign_key_exists(table_name: str, constrained_columns: list[str], referred_table: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, func:_foreign_key_names_for_column(table_name: str, column_name: str) → list[str], call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, call:names.append, func:upgrade() → None, call:_column_exists, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:sa.JSON, call:sa.Text, call:sa.Integer, call:sa.Boolean, call:_foreign_key_exists, call:op.create_foreign_key, call:_index_exists, call:op.create_index, call:op.alter_column, call:sa.String, call:_foreign_key_names_for_column, call:op.drop_constraint, call:op.drop_column, func:downgrade() → None, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:sa.Text, call:op.drop_column, call:op.alter_column, call:op.drop_index, call:op.drop_constraint | dep: collections.abc, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||
- 0014_merge_heads.py | Alembic merge migration that reconciles two divergent migration branches into a single history line | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
|
||||
- 0015_single_interface.py | Alembic database migration that replaces a JSON array `interfaces` column with `interface_type` string and `requires_port` boolean columns in the `tool_types` table, with dialect-specific data migration for PostgreSQL and SQLite. | exp: func:_get_dialect() → str, call:op.get_bind, func:upgrade() → None, call:_get_dialect, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Boolean, call:op.execute, call:op.alter_column, call:op.drop_column, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:_get_dialect, call:op.drop_constraint, call:op.add_column, call:sa.Column, call:postgresql.JSONB, call:sa.Text, call:op.execute, call:sa.JSON, call:op.drop_column | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, postgresql (dialect)
|
||||
- 069d3da4dc9b_add_ssh_key_id_to_config_profiles.py | Alembic database migration that adds a nullable UUID foreign key column `ssh_key_id` to the `config_profiles` table referencing `ssh_keys.id` with SET NULL on delete | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Uuid, call:sa.ForeignKey, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
|
||||
- 20260527160017_add_pi_agent_tool_type.py | Alembic database migration that adds a "pi-agent" terminal-based coding tool type to a tool_types table with Docker configuration templates | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'") ).fetchone, call:sa.text, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text | dep: json, typing, alembic, uuid, sqlalchemy
|
||||
- 2026_05_22_add_clone_mode.py | Alembic database migration that adds ssh_key_id foreign key to git_repositories table and clone_mode/branch columns to tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:op.create_foreign_key, call:sa.String, func:downgrade() → None, call:op.drop_column, call:op.drop_constraint | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||
- 2026_05_23_remove_is_builtin.py | Alembic database migration to remove the `is_builtin` column from the `tool_types` table | exp: func:upgrade() → None, call:op.execute, func:downgrade() → None, call:op.add_column, call:sa.Column, call:sa.Boolean | dep: alembic, sqlalchemy
|
||||
- 2026_05_24_220141_add_startup_command.py | Alembic database migration that adds a nullable `startup_command` text column to the `tool_types` table. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Text, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||
- 2026_05_24_add_config_profiles.py | Alembic database migration that creates config_profiles and config_profile_includes tables with indexes, and adds a foreign key column to tool_instances for managing user configuration profiles. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.ForeignKey, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:sa.Boolean, call:sa.DateTime, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:op.create_index, call:sa.Integer, call:op.add_column, func:downgrade() → None, call:op.drop_index, call:op.drop_column, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
|
||||
- 2026_05_26_add_git_mounts.py | Alembic database migration that adds a git_mounts JSON column to the config_profiles table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||
- 2026_05_27_external_repos.py | Alembic database migration that makes project_id nullable in git_repositories table to support external repositories and expands alembic_version version_num column to 64 characters. | exp: func:upgrade() → None, call:op.execute, call:op.alter_column, call:sa.UUID, func:downgrade() → None, call:op.alter_column, call:sa.UUID, call:op.execute | dep: typing, alembic, sqlalchemy
|
||||
- 2026_05_28_add_monitoring_tables.py | Alembic database migration that creates monitoring tables (instance_events and health_checks) with indexes for tracking tool instance events and health checks | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.Boolean, call:sa.Integer, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
||||
- 2026_05_28_add_terminal_sessions_table.py | Alembic database migration that creates a terminal_sessions table with tracking columns and foreign key to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:op.f, func:downgrade() → None, call:op.drop_index, call:op.f, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
||||
- 2026_05_28_add_tool_definition_manifests.py | Alembic database migration that creates a tool_definition_manifests table, adds manifest-related columns to tool_types and tool_instances, and migrates the pi-agent tool from Dockerfile-based to manifest-based definitions with seed data. | exp: func:upgrade() → None, call:op.get_bind, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:sa.ForeignKeyConstraint, call:sa.CheckConstraint, call:conn.execute, call:sa.text, call:result.fetchone, call:op.add_column, call:op.create_foreign_key, call:op.drop_constraint, call:op.execute, call:json.dumps, call:str, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_column, call:op.drop_constraint, call:op.drop_table | dep: json, uuid, typing, alembic, sqlalchemy
|
||||
- 2026_05_28_drop_tool_configs_and_config_folders.py | Alembic database migration that drops `tool_configs` and `config_folders` tables with conditional existence checks and full downgrade recreation | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_table, func:downgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.Integer | dep: typing, alembic, sqlalchemy
|
||||
- 2026_05_29_add_notifications_table.py | Alembic database migration that creates a notifications table with user-linked, categorized, severity-graded messages supporting read/dismissed tracking and optimized querying indexes. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.text, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
||||
- 2026_05_29_add_ssh_key_ids_to_tool_instances.py | Alembic database migration that adds a JSON column named ssh_key_ids to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
|
||||
- 2026_05_29_drop_ssh_key_id_from_config_profiles.py | Alembic database migration that removes the ssh_key_id column from the config_profiles table | exp: func:upgrade() → None, call:op.drop_column, func:downgrade() → None, call:op.add_column, call:sa.Column, call:sa.Uuid, call:sa.ForeignKey | dep: alembic, sqlalchemy
|
||||
- 2026_05_29_fix_code_server_bind_addr.py | Alembic database migration that fixes code-server tool type compose templates by replacing deprecated `--bind-addr` flag with `--host` flag | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text(""" SELECT id, compose_template FROM tool_types WHERE name = 'code-server' AND compose_template LIKE '%--bind-addr%' """) ).fetchall, call:sa.text, call:compose_template.replace( "--bind-addr 0.0.0.0:8443", "--host 0.0.0.0" ).replace, call:print, func:downgrade() → None | dep: typing, alembic, sqlalchemy
|
||||
- 2026_05_29_fix_code_server_bind_addr_port.py | Alembic database migration that fixes code-server Docker compose templates and instance files by replacing broken `--host` flags with correct `--bind-addr 0.0.0.0:port` configurations | exp: func:_fix_tool_type_templates(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, default_port FROM tool_types WHERE name = 'code-server' AND compose_template LIKE '%--host%' """) ).fetchall, call:sa.text, call:compose_template.split, call:len, call:line.lstrip, call:new_lines.append, call:"\n".join, call:print, func:_fix_instance_compose_files(conn) → None, call:conn.execute( sa.text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'tool_instances' AND column_name = 'compose_path' """) ).fetchone, call:sa.text, call:print, call:conn.execute( sa.text(""" SELECT id, compose_path, tool_type_id FROM tool_instances WHERE compose_path IS NOT NULL """) ).fetchall, call:Path, call:path.exists, call:path.read_text, call:conn.execute( sa.text(""" SELECT default_port FROM tool_types WHERE id = :id """), {"id": tool_type_id}, ).fetchone, call:yaml.safe_load, call:data["services"].values, call:path.write_text, call:yaml.dump, func:upgrade() → None, call:op.get_bind, call:_fix_tool_type_templates, call:_fix_instance_compose_files, func:downgrade() → None | dep: typing, alembic, yaml, pathlib, sqlalchemy
|
||||
- 2026_05_29_fix_web_tool_bind_address.py | Alembic database migration that updates code-server and jupyter-notebook tool type compose templates to bind to 0.0.0.0 | exp: func:_fix_code_server_compose(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, definition_type FROM tool_types WHERE name = 'code-server' """) ).fetchone, call:sa.text, call:compose_template.split, call:enumerate, call:len, call:line.lstrip, call:new_lines.append, call:image_line.lstrip, call:new_lines.index, call:new_lines.insert, call:"\n".join, call:print, func:_fix_jupyter_compose(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, definition_type FROM tool_types WHERE name = 'jupyter-notebook' """) ).fetchone, call:sa.text, call:compose_template.split, call:enumerate, call:new_lines.append, call:len, call:line.lstrip, call:"\n".join, call:print, func:upgrade() → None, call:op.get_bind, call:_fix_code_server_compose, call:_fix_jupyter_compose, func:downgrade() → None | dep: typing, alembic, sqlalchemy
|
||||
- 2026_05_29_remove_lsio_command_override.py | Alembic database migration that removes broken command overrides containing --bind-addr or --host flags from LinuxServer.io code-server Docker Compose templates in both database tool_types records and on-disk instance compose files. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text(""" SELECT id, compose_template FROM tool_types WHERE name = 'code-server' """) ).fetchall, call:sa.text, call:yaml.safe_load, call:data["services"].values, call:svc.get, call:yaml.dump, call:print, call:conn.execute( sa.text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'tool_instances' AND column_name = 'compose_path' """) ).fetchone, call:conn.execute( sa.text(""" SELECT id, compose_path FROM tool_instances WHERE compose_path IS NOT NULL """) ).fetchall, call:Path, call:path.exists, call:path.read_text, call:path.write_text, func:downgrade() → None | dep: collections.abc, alembic, yaml, pathlib, sqlalchemy, pathlib.Path, information_schema
|
||||
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py | Alembic database migration that removes (or restores) the ssh_keys mount from a JSON manifest stored in the tool_definition_manifests table for the pi-agent tool definition. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:any, call:m.get, call:mounts.append, call:json.dumps | dep: json, typing, alembic, sqlalchemy
|
||||
- 2026_06_01_add_workspaces.py | Alembic database migration that creates a workspaces table with foreign keys to git_repositories and users, adds indexes, and adds a workspace_id column to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.ForeignKey, call:sa.DateTime, call:sa.text, call:sa.UniqueConstraint, call:op.create_index, call:op.add_column, func:downgrade() → None, call:op.drop_index, call:op.drop_column, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
||||
- 2026_06_13_make_clone_mode_nullable.py | Alembic database migration that makes the `clone_mode` column in `tool_instances` table nullable to allow NULL values for new rows | exp: func:upgrade() → None, call:op.alter_column, call:sa.String, func:downgrade() → None, call:op.alter_column, call:sa.String | dep: alembic, sqlalchemy
|
||||
- 398082499c30_add_tool_config_fields.py | Alembic database migration that adds five new columns (port_override, start_command, working_directory, environment_variables, volumes) to the tool_configs table with a port range check constraint. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Integer, call:sa.Text, call:postgresql.JSONB, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:op.drop_constraint, call:op.drop_column | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py | Alembic database migration that merges two parallel revision branches (removing is_builtin and adding config_profiles) into a single history line | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
|
||||
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py | Alembic database migration that merges two divergent migration branches (profile resolver and workspaces) into a single head | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
|
||||
- 8c6d1dbd4798_remove_pi_config_and_state_mounts_from_.py | Alembic database migration that removes pi_state and pi_config mounts from the pi-agent manifest in upgrade, and restores them in downgrade | exp: func:_load_manifest(manifest_json), call:isinstance, call:json.loads, func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:_load_manifest, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:_load_manifest, call:manifest.get, call:m.get, call:mounts.append, call:json.dumps | dep: json, alembic, sqlalchemy
|
||||
- 8ed7dd80973d_create_config_folders_table.py | Alembic database migration that creates a config_folders table with user-owned configuration folders supporting JSONB file storage and project overrides | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.ForeignKey, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:sa.Boolean, call:sa.DateTime, call:sa.UniqueConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||
- af8512103d67_add_tool_type_fields.py | Alembic database migration that adds new columns (definition_type, dockerfile_template, build_context, readiness_probe) to the tool_types table with a CHECK constraint on definition_type. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:op.drop_constraint, call:op.drop_column | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||
- f3d2dc90ba3a_merge_single_interface_and_clone_mode.py | Alembic database migration that merges two prior revisions (single_interface and clone_mode) into a single migration path | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
|
||||
## arch
|
||||
Linear and branched migration pattern using Alembic's revision system with merge migrations to reconcile divergent branches; each migration is an imperative upgrade/downgrade script containing raw SQL/DDL operations, with some migrations including data seeding and dialect-specific logic (PostgreSQL/SQLite), but lacks consistent naming convention (mixed timestamp and numeric prefixes) indicating organic evolution rather than planned schema design.
|
||||
## tags
|
||||
column, table, call:op.drop, downgrade, alembic, upgrade, key, call:sa.text
|
||||
## symbols
|
||||
- upgrade
|
||||
- downgrade
|
||||
- _table_exists
|
||||
- _column_exists
|
||||
- _index_exists
|
||||
- _foreign_key_exists
|
||||
- _foreign_key_names_for_column
|
||||
- _get_dialect
|
||||
## workflows
|
||||
- change versions behavior
|
||||
read: 0001_initial_schema.py, 0002_refresh_tokens.py, 0003_user_configs.py
|
||||
- change versions CLI
|
||||
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
|
||||
- change versions config
|
||||
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,204 @@
|
||||
"""add config profiles, includes, mounts, and tool instance profile selection
|
||||
|
||||
Revision ID: 0013_add_config_profiles
|
||||
Revises: 0012_default_port_req
|
||||
Create Date: 2026-05-24 12:00:00.000000
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0013_add_config_profiles"
|
||||
down_revision: str | None = "0012_default_port_req"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return sa.inspect(op.get_bind()).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
return column_name in {
|
||||
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
|
||||
}
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
return index_name in {
|
||||
index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name)
|
||||
}
|
||||
|
||||
|
||||
def _foreign_key_exists(
|
||||
table_name: str,
|
||||
constrained_columns: list[str],
|
||||
referred_table: str,
|
||||
) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
|
||||
if (
|
||||
foreign_key.get("constrained_columns") == constrained_columns
|
||||
and foreign_key.get("referred_table") == referred_table
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Earlier branches may already have created config_profiles. Keep this
|
||||
# migration defensive so databases can converge onto the current graph.
|
||||
if not _table_exists("config_profiles"):
|
||||
op.create_table(
|
||||
"config_profiles",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"user_id", "name", name="uq_config_profiles_user_name"
|
||||
),
|
||||
)
|
||||
if not _index_exists("config_profiles", "idx_config_profiles_user"):
|
||||
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
|
||||
|
||||
if not _table_exists("config_includes"):
|
||||
op.create_table(
|
||||
"config_includes",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column(
|
||||
"included_profile_id", postgresql.UUID(as_uuid=True), nullable=False
|
||||
),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["profile_id"], ["config_profiles.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["included_profile_id"],
|
||||
["config_profiles.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"profile_id", "included_profile_id", name="uq_config_includes_pair"
|
||||
),
|
||||
)
|
||||
if not _index_exists("config_includes", "idx_config_includes_profile"):
|
||||
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
|
||||
if not _index_exists("config_includes", "idx_config_includes_included"):
|
||||
op.create_index(
|
||||
"idx_config_includes_included", "config_includes", ["included_profile_id"]
|
||||
)
|
||||
|
||||
if not _table_exists("config_mounts"):
|
||||
op.create_table(
|
||||
"config_mounts",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("mount_path", sa.String(length=1024), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["profile_id"], ["config_profiles.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
if not _index_exists("config_mounts", "idx_config_mounts_profile"):
|
||||
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
|
||||
|
||||
if not _column_exists("tool_instances", "selected_profile_id"):
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
if not _foreign_key_exists(
|
||||
"tool_instances", ["selected_profile_id"], "config_profiles"
|
||||
):
|
||||
op.create_foreign_key(
|
||||
"fk_tool_instances_selected_profile",
|
||||
"tool_instances",
|
||||
"config_profiles",
|
||||
["selected_profile_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
if not _index_exists("tool_instances", "idx_tool_instances_selected_profile"):
|
||||
op.create_index(
|
||||
"idx_tool_instances_selected_profile",
|
||||
"tool_instances",
|
||||
["selected_profile_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove selected_profile_id from tool_instances
|
||||
op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances")
|
||||
op.drop_constraint(
|
||||
"fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey"
|
||||
)
|
||||
op.drop_column("tool_instances", "selected_profile_id")
|
||||
|
||||
# Drop config_mounts
|
||||
op.drop_index("idx_config_mounts_profile", table_name="config_mounts")
|
||||
op.drop_table("config_mounts")
|
||||
|
||||
# Drop config_includes
|
||||
op.drop_index("idx_config_includes_included", table_name="config_includes")
|
||||
op.drop_index("idx_config_includes_profile", table_name="config_includes")
|
||||
op.drop_table("config_includes")
|
||||
|
||||
# Drop config_profiles
|
||||
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
|
||||
op.drop_table("config_profiles")
|
||||
@@ -0,0 +1,180 @@
|
||||
"""add profile resolver fields to config profiles and mounts
|
||||
|
||||
Revision ID: 0014_add_profile_resolver_fields
|
||||
Revises: 0013_add_config_profiles
|
||||
Create Date: 2026-05-24 14:00:00.000000
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0014_add_profile_resolver_fields"
|
||||
down_revision: str | None = "0013_add_config_profiles"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return sa.inspect(op.get_bind()).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
return column_name in {
|
||||
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
|
||||
}
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
return index_name in {
|
||||
index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name)
|
||||
}
|
||||
|
||||
|
||||
def _foreign_key_exists(
|
||||
table_name: str,
|
||||
constrained_columns: list[str],
|
||||
referred_table: str,
|
||||
) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
|
||||
if (
|
||||
foreign_key.get("constrained_columns") == constrained_columns
|
||||
and foreign_key.get("referred_table") == referred_table
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _foreign_key_names_for_column(table_name: str, column_name: str) -> list[str]:
|
||||
if not _table_exists(table_name):
|
||||
return []
|
||||
names: list[str] = []
|
||||
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
|
||||
if column_name in foreign_key.get("constrained_columns", []):
|
||||
name = foreign_key.get("name")
|
||||
if name:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _column_exists("config_profiles", "project_id"):
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
if not _column_exists("config_profiles", "tool_type_id"):
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
if not _column_exists("config_profiles", "environment_variables"):
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("environment_variables", sa.JSON(), nullable=True),
|
||||
)
|
||||
if not _column_exists("config_profiles", "start_command"):
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("start_command", sa.Text(), nullable=True),
|
||||
)
|
||||
if not _column_exists("config_profiles", "working_directory"):
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("working_directory", sa.Text(), nullable=True),
|
||||
)
|
||||
if not _column_exists("config_profiles", "port"):
|
||||
op.add_column("config_profiles", sa.Column("port", sa.Integer(), nullable=True))
|
||||
if not _column_exists("config_profiles", "is_default"):
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
if not _foreign_key_exists("config_profiles", ["project_id"], "projects"):
|
||||
op.create_foreign_key(
|
||||
"fk_config_profiles_project",
|
||||
"config_profiles",
|
||||
"projects",
|
||||
["project_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
if not _foreign_key_exists("config_profiles", ["tool_type_id"], "tool_types"):
|
||||
op.create_foreign_key(
|
||||
"fk_config_profiles_tool_type",
|
||||
"config_profiles",
|
||||
"tool_types",
|
||||
["tool_type_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
if not _index_exists("config_profiles", "idx_config_profiles_project"):
|
||||
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
|
||||
if not _index_exists("config_profiles", "idx_config_profiles_tool_type"):
|
||||
op.create_index(
|
||||
"idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"]
|
||||
)
|
||||
|
||||
if _column_exists("config_mounts", "mount_path") and not _column_exists(
|
||||
"config_mounts", "target_path"
|
||||
):
|
||||
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
|
||||
if not _column_exists("config_mounts", "mode"):
|
||||
op.add_column(
|
||||
"config_mounts",
|
||||
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
|
||||
)
|
||||
if not _column_exists("config_mounts", "files"):
|
||||
op.add_column(
|
||||
"config_mounts",
|
||||
sa.Column("files", sa.JSON(), nullable=True),
|
||||
)
|
||||
for constraint_name in _foreign_key_names_for_column(
|
||||
"config_mounts", "source_profile_id"
|
||||
):
|
||||
op.drop_constraint(constraint_name, "config_mounts", type_="foreignkey")
|
||||
if _column_exists("config_mounts", "content"):
|
||||
op.drop_column("config_mounts", "content")
|
||||
if _column_exists("config_mounts", "source_profile_id"):
|
||||
op.drop_column("config_mounts", "source_profile_id")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Restore config_mounts
|
||||
op.add_column(
|
||||
"config_mounts",
|
||||
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"config_mounts",
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
)
|
||||
op.drop_column("config_mounts", "files")
|
||||
op.drop_column("config_mounts", "mode")
|
||||
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
|
||||
|
||||
# Restore config_profiles
|
||||
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
|
||||
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
|
||||
op.drop_constraint(
|
||||
"fk_config_profiles_tool_type", "config_profiles", type_="foreignkey"
|
||||
)
|
||||
op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey")
|
||||
op.drop_column("config_profiles", "is_default")
|
||||
op.drop_column("config_profiles", "port")
|
||||
op.drop_column("config_profiles", "working_directory")
|
||||
op.drop_column("config_profiles", "start_command")
|
||||
op.drop_column("config_profiles", "environment_variables")
|
||||
op.drop_column("config_profiles", "tool_type_id")
|
||||
op.drop_column("config_profiles", "project_id")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add_ssh_key_id_to_config_profiles
|
||||
|
||||
Revision ID: 069d3da4dc9b
|
||||
Revises: 2026_05_29_add_notifications_table
|
||||
Create Date: 2026-05-29 12:30:16.580532
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "069d3da4dc9b"
|
||||
down_revision = "2026_05_29_add_notifications_table"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column(
|
||||
"ssh_key_id",
|
||||
sa.Uuid(),
|
||||
sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("config_profiles", "ssh_key_id")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""add monitoring tables
|
||||
|
||||
Revision ID: 2026_05_28_add_monitoring_tables
|
||||
Revises: 2026_05_28_drop_tool_configs_and_config_folders
|
||||
Create Date: 2026-05-28
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_28_add_monitoring_tables"
|
||||
down_revision: str | None = "2026_05_28_drop_tool_configs_and_config_folders"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"instance_events",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"instance_id",
|
||||
sa.Uuid(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("event_type", sa.String(length=50), nullable=False),
|
||||
sa.Column("status", sa.String(length=50), nullable=True),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column("created_by", sa.Uuid(), nullable=True),
|
||||
sa.Column(
|
||||
"metadata",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default="{}",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["instance_id"],
|
||||
["tool_instances.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by"],
|
||||
["users.id"],
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_instance_events_instance_id",
|
||||
"instance_events",
|
||||
["instance_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"idx_instance_events_created_at",
|
||||
"instance_events",
|
||||
["created_at"],
|
||||
postgresql_using="btree",
|
||||
)
|
||||
op.create_index(
|
||||
"idx_instance_events_event_type",
|
||||
"instance_events",
|
||||
["event_type"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"health_checks",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"instance_id",
|
||||
sa.Uuid(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("container_status", sa.String(length=50), nullable=True),
|
||||
sa.Column("container_healthy", sa.Boolean(), nullable=True),
|
||||
sa.Column("tunnel_healthy", sa.Boolean(), nullable=True),
|
||||
sa.Column("exit_code", sa.Integer(), nullable=True),
|
||||
sa.Column("probe_status", sa.String(length=50), nullable=True),
|
||||
sa.Column("probe_output", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"checked_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["instance_id"],
|
||||
["tool_instances.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_health_checks_instance_id",
|
||||
"health_checks",
|
||||
["instance_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"idx_health_checks_checked_at",
|
||||
"health_checks",
|
||||
["checked_at"],
|
||||
postgresql_using="btree",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_health_checks_checked_at", table_name="health_checks")
|
||||
op.drop_index("idx_health_checks_instance_id", table_name="health_checks")
|
||||
op.drop_table("health_checks")
|
||||
op.drop_index("idx_instance_events_event_type", table_name="instance_events")
|
||||
op.drop_index("idx_instance_events_created_at", table_name="instance_events")
|
||||
op.drop_index("idx_instance_events_instance_id", table_name="instance_events")
|
||||
op.drop_table("instance_events")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""add terminal_sessions table
|
||||
|
||||
Revision ID: 2026_05_28_add_terminal_sessions
|
||||
Revises: 20260527_160017_add_pi_agent
|
||||
Create Date: 2026-05-28
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_28_add_terminal_sessions"
|
||||
down_revision: str | None = "2026_05_28_add_tool_definition_manifests"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"terminal_sessions",
|
||||
sa.Column("id", sa.UUID(), nullable=False),
|
||||
sa.Column("instance_id", sa.UUID(), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=50), nullable=False),
|
||||
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
onupdate=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["instance_id"], ["tool_instances.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_terminal_sessions_instance_id"),
|
||||
"terminal_sessions",
|
||||
["instance_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_terminal_sessions_instance_id"),
|
||||
table_name="terminal_sessions",
|
||||
)
|
||||
op.drop_table("terminal_sessions")
|
||||
@@ -232,28 +232,6 @@ def upgrade() -> None:
|
||||
"writable": True,
|
||||
"owner": "user",
|
||||
},
|
||||
{
|
||||
"name": "ssh_keys",
|
||||
"target": "/home/user/.ssh",
|
||||
"source_type": "ssh_key",
|
||||
"mode": "0700",
|
||||
"file_mode": "0600",
|
||||
"readonly": True,
|
||||
},
|
||||
{
|
||||
"name": "pi_state",
|
||||
"target": "/tmp/.pi/agents",
|
||||
"source_type": "instance",
|
||||
"writable": True,
|
||||
},
|
||||
{
|
||||
"name": "pi_config",
|
||||
"target": "/home/user/.pi",
|
||||
"source_type": "git_mount",
|
||||
"git_mount_ref": "dotfiles",
|
||||
"writable": True,
|
||||
"owner": "user",
|
||||
},
|
||||
],
|
||||
"runtime": {
|
||||
"command": ["/bin/bash"],
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""drop tool_configs and config_folders tables
|
||||
|
||||
Revision ID: 2026_05_28_drop_tool_configs_and_config_folders
|
||||
Revises: 2026_05_28_add_tool_definition_manifests
|
||||
Create Date: 2026-05-28
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_28_drop_tool_configs_and_config_folders"
|
||||
down_revision: Union[str, None] = "2026_05_28_add_terminal_sessions"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Drop tool_configs table if it exists
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_name = 'tool_configs'
|
||||
""")
|
||||
)
|
||||
if result.fetchone():
|
||||
op.drop_table("tool_configs")
|
||||
|
||||
# Drop config_folders table if it exists
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_name = 'config_folders'
|
||||
""")
|
||||
)
|
||||
if result.fetchone():
|
||||
op.drop_table("config_folders")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate config_folders table
|
||||
op.create_table(
|
||||
"config_folders",
|
||||
sa.Column("id", sa.UUID(), nullable=False),
|
||||
sa.Column("user_id", sa.UUID(), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("mount_path", sa.String(1024), nullable=False),
|
||||
sa.Column("files", sa.JSON(), default=dict, nullable=False),
|
||||
sa.Column("project_overrides", sa.JSON(), default=dict, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), default=True, nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
# Recreate tool_configs table
|
||||
op.create_table(
|
||||
"tool_configs",
|
||||
sa.Column("id", sa.UUID(), nullable=False),
|
||||
sa.Column("user_id", sa.UUID(), nullable=False),
|
||||
sa.Column("tool_type_id", sa.UUID(), nullable=False),
|
||||
sa.Column("project_id", sa.UUID(), nullable=True),
|
||||
sa.Column("key", sa.String(255), nullable=False),
|
||||
sa.Column("value", sa.Text(), nullable=False),
|
||||
sa.Column("config_type", sa.String(20), default="env", nullable=False),
|
||||
sa.Column("file_path", sa.String(1024), nullable=True),
|
||||
sa.Column("port_override", sa.Integer(), nullable=True),
|
||||
sa.Column("start_command", sa.Text(), nullable=True),
|
||||
sa.Column("working_directory", sa.Text(), nullable=True),
|
||||
sa.Column("environment_variables", sa.JSON(), default=dict, nullable=True),
|
||||
sa.Column("volumes", sa.JSON(), default=list, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""add notifications table
|
||||
|
||||
Revision ID: 2026_05_29_add_notifications_table
|
||||
Revises: 2026_05_28_add_monitoring_tables
|
||||
Create Date: 2026-05-29
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_29_add_notifications_table"
|
||||
down_revision: str | None = "2026_05_28_add_monitoring_tables"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"notifications",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("category", sa.String(length=32), nullable=False),
|
||||
sa.Column("severity", sa.String(length=16), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("source_id", sa.Uuid(), nullable=True),
|
||||
sa.Column(
|
||||
"metadata",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default="{}",
|
||||
),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("dismissed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_notifications_user_created_at",
|
||||
"notifications",
|
||||
["user_id", sa.text("created_at DESC")],
|
||||
)
|
||||
op.create_index(
|
||||
"idx_notifications_user_unread",
|
||||
"notifications",
|
||||
["user_id", "read_at"],
|
||||
postgresql_where=sa.text("read_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_notifications_user_unread", table_name="notifications")
|
||||
op.drop_index("idx_notifications_user_created_at", table_name="notifications")
|
||||
op.drop_table("notifications")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""add_ssh_key_ids_to_tool_instances
|
||||
|
||||
Revision ID: 2026_05_29_add_ssh_key_ids_to_tool_instances
|
||||
Revises: 2026_05_29_drop_ssh_key_id_from_config_profiles
|
||||
Create Date: 2026-05-29 12:46:00.000000
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "2026_05_29_add_ssh_key_ids_to_tool_instances"
|
||||
down_revision = "2026_05_29_drop_ssh_key_id_from_config_profiles"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column("ssh_key_ids", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tool_instances", "ssh_key_ids")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""drop_ssh_key_id_from_config_profiles
|
||||
|
||||
Revision ID: 2026_05_29_drop_ssh_key_id_from_config_profiles
|
||||
Revises: 069d3da4dc9b
|
||||
Create Date: 2026-05-29 12:45:00.000000
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "2026_05_29_drop_ssh_key_id_from_config_profiles"
|
||||
down_revision = "069d3da4dc9b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("config_profiles", "ssh_key_id")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column(
|
||||
"ssh_key_id",
|
||||
sa.Uuid(),
|
||||
sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""fix code-server bind-addr to host in DB template
|
||||
|
||||
Revision ID: 2026_05_29_fix_code_server_bind_addr
|
||||
Revises: 2026_05_29_fix_web_tool_bind_address
|
||||
Create Date: 2026-05-29 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_29_fix_code_server_bind_addr"
|
||||
down_revision: str | None = "2026_05_29_fix_web_tool_bind_address"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Find code-server tool types with broken --bind-addr in compose template
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT id, compose_template
|
||||
FROM tool_types
|
||||
WHERE name = 'code-server'
|
||||
AND compose_template LIKE '%--bind-addr%'
|
||||
""")
|
||||
).fetchall()
|
||||
|
||||
for tool_id, compose_template in result:
|
||||
updated = compose_template.replace(
|
||||
"--bind-addr 0.0.0.0:8443", "--host 0.0.0.0"
|
||||
).replace("--bind-addr", "--host 0.0.0.0")
|
||||
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE tool_types
|
||||
SET compose_template = :compose_template
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"compose_template": updated, "id": tool_id},
|
||||
)
|
||||
|
||||
print(
|
||||
f"Fixed code-server template ({tool_id}): replaced --bind-addr with --host"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Fix code-server bind address to include port
|
||||
|
||||
Revision ID: 2026_05_29_fix_code_server_bind_addr_port
|
||||
Revises: 2026_05_29_remove_lsio_command_override
|
||||
Create Date: 2026-05-29 18:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import yaml
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_29_fix_code_server_bind_addr_port"
|
||||
down_revision: Union[str, None] = "2026_05_29_remove_lsio_command_override"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _fix_tool_type_templates(conn) -> None:
|
||||
"""Fix code-server tool type templates with broken --host override."""
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT id, compose_template, default_port
|
||||
FROM tool_types
|
||||
WHERE name = 'code-server'
|
||||
AND compose_template LIKE '%--host%'
|
||||
""")
|
||||
).fetchall()
|
||||
|
||||
for tool_id, compose_template, default_port in result:
|
||||
port = default_port or 8443
|
||||
expected = f"--bind-addr 0.0.0.0:{port}"
|
||||
|
||||
# Replace any line containing --host with the correct bind-addr
|
||||
lines = compose_template.split("\n")
|
||||
new_lines = []
|
||||
modified = False
|
||||
for line in lines:
|
||||
if "command:" in line and "--host" in line:
|
||||
indent = line[: len(line) - len(line.lstrip())]
|
||||
new_lines.append(f"{indent}command: {expected}")
|
||||
modified = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
if not modified:
|
||||
continue
|
||||
|
||||
updated = "\n".join(new_lines)
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE tool_types
|
||||
SET compose_template = :compose_template
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"compose_template": updated, "id": tool_id},
|
||||
)
|
||||
print(f"Fixed code-server template ({tool_id}): replaced --host with {expected}")
|
||||
|
||||
|
||||
def _fix_instance_compose_files(conn) -> None:
|
||||
"""Fix existing instance compose files on disk with broken --host override."""
|
||||
from pathlib import Path
|
||||
|
||||
# Use information_schema to check if compose_path column exists
|
||||
col_result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'tool_instances'
|
||||
AND column_name = 'compose_path'
|
||||
""")
|
||||
).fetchone()
|
||||
|
||||
if not col_result:
|
||||
print("compose_path column not found, skipping instance file fixes")
|
||||
return
|
||||
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT id, compose_path, tool_type_id
|
||||
FROM tool_instances
|
||||
WHERE compose_path IS NOT NULL
|
||||
""")
|
||||
).fetchall()
|
||||
|
||||
for instance_id, compose_path, tool_type_id in result:
|
||||
path = Path(compose_path)
|
||||
if not path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
content = path.read_text()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if "--host" not in content:
|
||||
continue
|
||||
|
||||
# Get default_port from tool_type
|
||||
port_result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT default_port FROM tool_types WHERE id = :id
|
||||
"""),
|
||||
{"id": tool_type_id},
|
||||
).fetchone()
|
||||
port = port_result[0] if port_result and port_result[0] else 8443
|
||||
expected = f"--bind-addr 0.0.0.0:{port}"
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(content)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not data or "services" not in data:
|
||||
continue
|
||||
|
||||
modified = False
|
||||
for svc in data["services"].values():
|
||||
if "command" in svc:
|
||||
cmd = svc["command"]
|
||||
if "--host" in cmd:
|
||||
svc["command"] = expected
|
||||
modified = True
|
||||
|
||||
if not modified:
|
||||
continue
|
||||
|
||||
try:
|
||||
path.write_text(yaml.dump(data, default_flow_style=False))
|
||||
print(
|
||||
f"Fixed code-server instance compose ({instance_id}): "
|
||||
f"replaced --host with {expected}"
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Failed to fix instance {instance_id}: {exc}")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
_fix_tool_type_templates(conn)
|
||||
_fix_instance_compose_files(conn)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,140 @@
|
||||
"""fix web tool bind address to 0.0.0.0
|
||||
|
||||
Revision ID: 2026_05_29_fix_web_tool_bind_address
|
||||
Revises: 2026_05_29_remove_ssh_keys_mount_from_manifest
|
||||
Create Date: 2026-05-29 14:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_29_fix_web_tool_bind_address"
|
||||
down_revision: Union[str, None] = "2026_05_29_remove_ssh_keys_mount_from_manifest"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _fix_code_server_compose(conn) -> None:
|
||||
"""Update code-server compose template to bind to 0.0.0.0."""
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT id, compose_template, definition_type
|
||||
FROM tool_types
|
||||
WHERE name = 'code-server'
|
||||
""")
|
||||
).fetchone()
|
||||
|
||||
if result is None:
|
||||
return
|
||||
|
||||
tool_id, compose_template, definition_type = result
|
||||
|
||||
if definition_type != "compose" or not compose_template:
|
||||
return
|
||||
|
||||
# Fix or add command to bind to 0.0.0.0
|
||||
lines = compose_template.split("\n")
|
||||
new_lines = []
|
||||
image_line_idx = -1
|
||||
command_fixed = False
|
||||
for i, line in enumerate(lines):
|
||||
# Replace broken --bind-addr with correct --host
|
||||
if "command:" in line and "--bind-addr" in line:
|
||||
indent = line[: len(line) - len(line.lstrip())]
|
||||
new_lines.append(f"{indent}command: --host 0.0.0.0")
|
||||
command_fixed = True
|
||||
continue
|
||||
new_lines.append(line)
|
||||
if "image:" in line and image_line_idx == -1:
|
||||
image_line_idx = i
|
||||
|
||||
# If no command line exists, insert one after image
|
||||
if not command_fixed and image_line_idx != -1:
|
||||
image_line = lines[image_line_idx]
|
||||
indent = image_line[: len(image_line) - len(image_line.lstrip())]
|
||||
# Insert after the image line in new_lines
|
||||
insert_idx = new_lines.index(image_line) + 1
|
||||
new_lines.insert(insert_idx, f"{indent}command: --host 0.0.0.0")
|
||||
command_fixed = True
|
||||
|
||||
if not command_fixed:
|
||||
return
|
||||
|
||||
updated_compose = "\n".join(new_lines)
|
||||
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE tool_types
|
||||
SET compose_template = :compose_template
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"compose_template": updated_compose, "id": tool_id},
|
||||
)
|
||||
|
||||
print(f"Updated code-server tool type ({tool_id}) to bind to 0.0.0.0")
|
||||
|
||||
|
||||
def _fix_jupyter_compose(conn) -> None:
|
||||
"""Update jupyter-notebook compose template to bind to 0.0.0.0."""
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT id, compose_template, definition_type
|
||||
FROM tool_types
|
||||
WHERE name = 'jupyter-notebook'
|
||||
""")
|
||||
).fetchone()
|
||||
|
||||
if result is None:
|
||||
return
|
||||
|
||||
tool_id, compose_template, definition_type = result
|
||||
|
||||
if definition_type != "compose" or not compose_template:
|
||||
return
|
||||
|
||||
if "command:" in compose_template:
|
||||
return
|
||||
|
||||
lines = compose_template.split("\n")
|
||||
new_lines = []
|
||||
image_line_idx = -1
|
||||
for i, line in enumerate(lines):
|
||||
new_lines.append(line)
|
||||
if "image:" in line and image_line_idx == -1:
|
||||
image_line_idx = i
|
||||
indent = line[: len(line) - len(line.lstrip())]
|
||||
# Jupyter needs --ip=0.0.0.0 to bind to all interfaces
|
||||
new_lines.append(
|
||||
f"{indent}command: start-notebook.sh --ip=0.0.0.0 --port=8888 --no-browser"
|
||||
)
|
||||
|
||||
if image_line_idx == -1:
|
||||
return
|
||||
|
||||
updated_compose = "\n".join(new_lines)
|
||||
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE tool_types
|
||||
SET compose_template = :compose_template
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"compose_template": updated_compose, "id": tool_id},
|
||||
)
|
||||
|
||||
print(f"Updated jupyter-notebook tool type ({tool_id}) to bind to 0.0.0.0:8888")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
_fix_code_server_compose(conn)
|
||||
_fix_jupyter_compose(conn)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Cannot safely downgrade without knowing the original compose_template
|
||||
pass
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Remove broken command override from LSIO code-server templates
|
||||
|
||||
Revision ID: 2026_05_29_remove_lsio_command_override
|
||||
Revises: 2026_05_29_fix_code_server_bind_addr
|
||||
Create Date: 2026-05-29 15:05:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_29_remove_lsio_command_override"
|
||||
down_revision: str | None = "2026_05_29_fix_code_server_bind_addr"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Fix tool_types templates in DB
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT id, compose_template
|
||||
FROM tool_types
|
||||
WHERE name = 'code-server'
|
||||
""")
|
||||
).fetchall()
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
for tool_id, compose_template in result:
|
||||
try:
|
||||
data = yaml.safe_load(compose_template)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not data or "services" not in data:
|
||||
continue
|
||||
|
||||
modified = False
|
||||
for svc in data["services"].values():
|
||||
image = svc.get("image", "")
|
||||
if not image or "linuxserver" not in image:
|
||||
continue
|
||||
if "command" in svc:
|
||||
cmd = svc["command"]
|
||||
if "--bind-addr" in cmd or "--host" in cmd:
|
||||
del svc["command"]
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
updated = yaml.dump(data, default_flow_style=False)
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE tool_types
|
||||
SET compose_template = :compose_template
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"compose_template": updated, "id": tool_id},
|
||||
)
|
||||
print(f"Removed broken command override from LSIO template ({tool_id})")
|
||||
|
||||
# Fix existing instance compose files on disk
|
||||
# Use information_schema to check if compose_path column exists
|
||||
col_result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'tool_instances'
|
||||
AND column_name = 'compose_path'
|
||||
""")
|
||||
).fetchone()
|
||||
|
||||
if col_result:
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT id, compose_path
|
||||
FROM tool_instances
|
||||
WHERE compose_path IS NOT NULL
|
||||
""")
|
||||
).fetchall()
|
||||
|
||||
for instance_id, compose_path in result:
|
||||
path = Path(compose_path)
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
content = path.read_text()
|
||||
data = yaml.safe_load(content)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not data or "services" not in data:
|
||||
continue
|
||||
|
||||
modified = False
|
||||
for svc in data["services"].values():
|
||||
image = svc.get("image", "")
|
||||
if not image or "linuxserver" not in image:
|
||||
continue
|
||||
if "command" in svc:
|
||||
cmd = svc["command"]
|
||||
if "--bind-addr" in cmd or "--host" in cmd:
|
||||
del svc["command"]
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
path.write_text(yaml.dump(data, default_flow_style=False))
|
||||
print(
|
||||
f"Removed broken command override from instance compose "
|
||||
f"({instance_id})"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,105 @@
|
||||
"""remove ssh_keys mount from pi-agent manifest
|
||||
|
||||
Revision ID: 2026_05_29_remove_ssh_keys_mount_from_manifest
|
||||
Revises: 2026_05_29_add_ssh_key_ids_to_tool_instances
|
||||
Create Date: 2026-05-29 14:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_29_remove_ssh_keys_mount_from_manifest"
|
||||
down_revision: Union[str, None] = "2026_05_29_add_ssh_key_ids_to_tool_instances"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Remove the ssh_keys mount from the pi-agent manifest."""
|
||||
conn = op.get_bind()
|
||||
|
||||
# Get the pi-agent manifest
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, manifest FROM tool_definition_manifests WHERE name = 'pi-agent'"
|
||||
)
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
return
|
||||
|
||||
manifest_id, manifest_json = row
|
||||
manifest = (
|
||||
manifest_json if isinstance(manifest_json, dict) else json.loads(manifest_json)
|
||||
)
|
||||
|
||||
mounts = manifest.get("mounts", [])
|
||||
original_count = len(mounts)
|
||||
|
||||
# Remove any mount named "ssh_keys"
|
||||
filtered_mounts = [m for m in mounts if m.get("name") != "ssh_keys"]
|
||||
|
||||
if len(filtered_mounts) < original_count:
|
||||
manifest["mounts"] = filtered_mounts
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE tool_definition_manifests SET manifest = :manifest WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"manifest": json.dumps(manifest),
|
||||
"id": manifest_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Restore the ssh_keys mount to the pi-agent manifest."""
|
||||
conn = op.get_bind()
|
||||
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, manifest FROM tool_definition_manifests WHERE name = 'pi-agent'"
|
||||
)
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
return
|
||||
|
||||
manifest_id, manifest_json = row
|
||||
manifest = (
|
||||
manifest_json if isinstance(manifest_json, dict) else json.loads(manifest_json)
|
||||
)
|
||||
|
||||
mounts = manifest.get("mounts", [])
|
||||
|
||||
# Check if ssh_keys mount already exists
|
||||
if any(m.get("name") == "ssh_keys" for m in mounts):
|
||||
return
|
||||
|
||||
# Add the ssh_keys mount back
|
||||
mounts.append(
|
||||
{
|
||||
"name": "ssh_keys",
|
||||
"target": "/home/user/.ssh",
|
||||
"source_type": "ssh_key",
|
||||
"mode": "0700",
|
||||
"file_mode": "0600",
|
||||
"readonly": True,
|
||||
}
|
||||
)
|
||||
manifest["mounts"] = mounts
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE tool_definition_manifests SET manifest = :manifest WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"manifest": json.dumps(manifest),
|
||||
"id": manifest_id,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""add workspaces table
|
||||
|
||||
Revision ID: 2026_06_01_add_workspaces
|
||||
Revises: 2026_05_29_fix_code_server_bind_addr_port
|
||||
Create Date: 2026-06-01 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_06_01_add_workspaces"
|
||||
down_revision: str | None = "2026_05_29_fix_code_server_bind_addr_port"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create workspaces table
|
||||
op.create_table(
|
||||
"workspaces",
|
||||
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"repo_id",
|
||||
sa.Uuid(as_uuid=True),
|
||||
sa.ForeignKey("git_repositories.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Uuid(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("branch", sa.String(255), nullable=False, server_default="main"),
|
||||
sa.Column("path", sa.String(2048), nullable=False),
|
||||
sa.Column("status", sa.String(16), nullable=False, server_default="ready"),
|
||||
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
op.create_index("idx_workspaces_repo_id", "workspaces", ["repo_id"])
|
||||
op.create_index("idx_workspaces_user_id", "workspaces", ["user_id"])
|
||||
op.create_index("idx_workspaces_status", "workspaces", ["status"])
|
||||
|
||||
# Add workspace_id to tool_instances
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column(
|
||||
"workspace_id",
|
||||
sa.Uuid(as_uuid=True),
|
||||
sa.ForeignKey("workspaces.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_tool_instances_workspace_id", "tool_instances", ["workspace_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_tool_instances_workspace_id", table_name="tool_instances")
|
||||
op.drop_column("tool_instances", "workspace_id")
|
||||
op.drop_table("workspaces")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""make clone_mode nullable
|
||||
|
||||
Revision ID: 2026_06_13_make_clone_mode_nullable
|
||||
Revises: 86cec91fdb00
|
||||
Create Date: 2026-06-13 10:00:00.000000
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "2026_06_13_make_clone_mode_nullable"
|
||||
down_revision = "86cec91fdb00"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# The workspace-first cleanup no longer writes clone_mode; existing rows
|
||||
# keep their value, but new rows may be NULL.
|
||||
op.alter_column(
|
||||
"tool_instances",
|
||||
"clone_mode",
|
||||
existing_type=sa.String(20),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"tool_instances",
|
||||
"clone_mode",
|
||||
existing_type=sa.String(20),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""merge profile resolver and workspaces heads
|
||||
|
||||
Revision ID: 86cec91fdb00
|
||||
Revises: 0014_add_profile_resolver_fields, 2026_06_01_add_workspaces
|
||||
Create Date: 2026-06-03 12:48:36.145702
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "86cec91fdb00"
|
||||
down_revision = ("0014_add_profile_resolver_fields", "2026_06_01_add_workspaces")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,112 @@
|
||||
"""remove pi config and state mounts from pi-agent manifest
|
||||
|
||||
Revision ID: 8c6d1dbd4798
|
||||
Revises: 2026_06_13_make_clone_mode_nullable
|
||||
Create Date: 2026-06-13 11:21:25.983178
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '8c6d1dbd4798'
|
||||
down_revision = '2026_06_13_make_clone_mode_nullable'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _load_manifest(manifest_json):
|
||||
return manifest_json if isinstance(manifest_json, dict) else json.loads(manifest_json)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Remove pi_state and pi_config mounts from the pi-agent manifest."""
|
||||
conn = op.get_bind()
|
||||
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, manifest FROM tool_definition_manifests WHERE name = 'pi-agent'"
|
||||
)
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
return
|
||||
|
||||
manifest_id, manifest_json = row
|
||||
manifest = _load_manifest(manifest_json)
|
||||
|
||||
mounts = manifest.get("mounts", [])
|
||||
original_count = len(mounts)
|
||||
|
||||
filtered_mounts = [
|
||||
m for m in mounts if m.get("name") not in ("pi_state", "pi_config")
|
||||
]
|
||||
|
||||
if len(filtered_mounts) < original_count:
|
||||
manifest["mounts"] = filtered_mounts
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE tool_definition_manifests SET manifest = :manifest WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"manifest": json.dumps(manifest),
|
||||
"id": manifest_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Restore pi_state and pi_config mounts to the pi-agent manifest."""
|
||||
conn = op.get_bind()
|
||||
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, manifest FROM tool_definition_manifests WHERE name = 'pi-agent'"
|
||||
)
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
return
|
||||
|
||||
manifest_id, manifest_json = row
|
||||
manifest = _load_manifest(manifest_json)
|
||||
|
||||
mounts = manifest.get("mounts", [])
|
||||
existing_names = {m.get("name") for m in mounts}
|
||||
|
||||
if "pi_state" not in existing_names:
|
||||
mounts.append(
|
||||
{
|
||||
"name": "pi_state",
|
||||
"target": "/tmp/.pi/agents",
|
||||
"source_type": "instance",
|
||||
"writable": True,
|
||||
}
|
||||
)
|
||||
|
||||
if "pi_config" not in existing_names:
|
||||
mounts.append(
|
||||
{
|
||||
"name": "pi_config",
|
||||
"target": "/home/user/.pi",
|
||||
"source_type": "git_mount",
|
||||
"git_mount_ref": "dotfiles",
|
||||
"writable": True,
|
||||
"owner": "user",
|
||||
}
|
||||
)
|
||||
|
||||
manifest["mounts"] = mounts
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE tool_definition_manifests SET manifest = :manifest WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"manifest": json.dumps(manifest),
|
||||
"id": manifest_id,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
# apps/api/src (index)
|
||||
dir: apps/api/src
|
||||
|
||||
## role
|
||||
Core API application package that initializes and configures the Headquarter FastAPI backend with database, authentication, logging, and middleware infrastructure.
|
||||
## parent
|
||||
index: apps/api/.pi-map.index.md
|
||||
map: apps/api/.pi-map.md
|
||||
## children
|
||||
- apps/api/src/api
|
||||
index: apps/api/src/api/.pi-map.index.md
|
||||
map: apps/api/src/api/.pi-map.md
|
||||
- apps/api/src/auth
|
||||
index: apps/api/src/auth/.pi-map.index.md
|
||||
map: apps/api/src/auth/.pi-map.md
|
||||
- apps/api/src/headquarter_api.egg-info
|
||||
index: apps/api/src/headquarter_api.egg-info/.pi-map.index.md
|
||||
map: apps/api/src/headquarter_api.egg-info/.pi-map.md
|
||||
- apps/api/src/models
|
||||
index: apps/api/src/models/.pi-map.index.md
|
||||
map: apps/api/src/models/.pi-map.md
|
||||
- apps/api/src/schemas
|
||||
index: apps/api/src/schemas/.pi-map.index.md
|
||||
map: apps/api/src/schemas/.pi-map.md
|
||||
- apps/api/src/scripts
|
||||
index: apps/api/src/scripts/.pi-map.index.md
|
||||
map: apps/api/src/scripts/.pi-map.md
|
||||
- apps/api/src/seeds
|
||||
index: apps/api/src/seeds/.pi-map.index.md
|
||||
map: apps/api/src/seeds/.pi-map.md
|
||||
- apps/api/src/services
|
||||
index: apps/api/src/services/.pi-map.index.md
|
||||
map: apps/api/src/services/.pi-map.md
|
||||
- apps/api/src/utils
|
||||
index: apps/api/src/utils/.pi-map.index.md
|
||||
map: apps/api/src/utils/.pi-map.md
|
||||
## files
|
||||
- __init__.py
|
||||
- config.py
|
||||
- database.py
|
||||
- logging_config.py
|
||||
- main.py
|
||||
## links
|
||||
index: apps/api/src/.pi-map.index.md
|
||||
map: apps/api/src/.pi-map.md
|
||||
## workflows
|
||||
- change src behavior
|
||||
read: __init__.py, config.py, database.py
|
||||
- change src config
|
||||
read: config.py, logging_config.py
|
||||
- explore src subdirectories
|
||||
index: apps/api/src/api/.pi-map.index.md, apps/api/src/auth/.pi-map.index.md, apps/api/src/headquarter_api.egg-info/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,35 @@
|
||||
# apps/api/src
|
||||
dir: apps/api/src
|
||||
|
||||
index: apps/api/src/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Core API application package that initializes and configures the Headquarter FastAPI backend with database, authentication, logging, and middleware infrastructure.
|
||||
## files
|
||||
- __init__.py | Marks the directory as a Python package for the Headquarter API.
|
||||
- config.py | Defines application configuration settings with environment-based overrides using Pydantic, including database URLs, service domains, OAuth/Authentik integration, JWT/session settings, and computed properties for environment-specific behavior. | exp: class:Settings, func:build_database_url(user: str, password: str, host: str, port: int, database: str) → str | dep: pydantic, pydantic_settings
|
||||
- database.py | Sets up async SQLAlchemy database engine and session factory, with retry logic for database connectivity testing and Alembic migration execution via subprocess. | exp: func:init_database(max_retries, retry_delay) → bool, call:range, call:engine.connect, call:test_conn.execute, call:text, call:test_conn.close, call:logger.info, call:asyncio.get_event_loop().run_in_executor, call:subprocess.run, call:os.path.dirname, call:os.path.abspath, call:logger.debug, call:logger.error, call:asyncio.sleep, call:str(exc).lower, call:logger.warning | dep: asyncio, logging, os, subprocess, sqlalchemy.ext.asyncio, sqlalchemy.pool, src.config, sqlalchemy
|
||||
- logging_config.py | Configures structured JSON logging with correlation ID injection, custom formatters, and HTTP request/exception middleware for a FastAPI application. | exp: class:CorrelationIdFilter, method:filter(self, record: logging.LogRecord) → bool, call:get_correlation_id, class:JSONFormatter, method:format(self, record: logging.LogRecord) → str, call:self.formatTime, call:record.getMessage, call:getattr, call:self.formatException, call:json.dumps, method:formatTime(self, record: logging.LogRecord, datefmt) → str, call:time.strftime, call:time.gmtime, class:RequestLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:time.time, call:logger.info, call:call_next, call:int, call:logger.error, call:type, call:traceback.format_exc, class:ExceptionLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:call_next, call:logger.critical, call:traceback.format_exc, func:configure_logging(level) → None, call:JSONFormatter, call:logging.StreamHandler, call:console_handler.setFormatter, call:console_handler.addFilter, call:CorrelationIdFilter, call:root_logger.setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("sqlalchemy.engine").setLevel, call:logger.info, call:logging.getLevelName | dep: json, logging, sys, time, traceback, collections.abc, fastapi, starlette.middleware.base, src.services.shared.correlation
|
||||
- main.py | Initializes and configures a FastAPI application for the "Headquarter API" with database setup, middleware, routing, and background services. | exp: func:_sanitize_validation_errors(errors), call:error.get, call:str, call:ctx.items, call:isinstance, call:type, call:sanitized.append, func:validation_exception_handler(request: Request, exc: RequestValidationError), call:exc.errors, call:logger.warning, call:_sanitize_validation_errors, call:JSONResponse, func:on_startup(), call:logger.info, call:init_database, call:logger.error, call:sys.exit, call:_health_monitor.start, call:seed_builtin_tool_types, func:on_shutdown(), call:logger.info, call:_health_monitor.stop | dep: logging, os, fastapi, fastapi.exceptions, fastapi.middleware.cors, fastapi.responses, fastapi.staticfiles, src.api.config, src.api.project, src.api.system, src.api.tool, src.api.user, src.api.workspace, src.config, src.models, src.database, src.logging_config, src.seeds.builtin_tool_types, src.services.instance, src.services.shared, sys, src.api.*
|
||||
## arch
|
||||
Layered architecture using Pydantic for environment-based configuration, async SQLAlchemy with Alembic migrations, structured JSON logging with correlation IDs, and FastAPI middleware/routing pattern for a service-oriented backend.
|
||||
## tags
|
||||
src, database, logging, call:logger.info, api, middleware, fastapi, filter
|
||||
## symbols
|
||||
- Settings
|
||||
- CorrelationIdFilter
|
||||
- JSONFormatter
|
||||
- RequestLoggingMiddleware
|
||||
- ExceptionLoggingMiddleware
|
||||
- build_database_url
|
||||
- init_database
|
||||
- filter
|
||||
## workflows
|
||||
- change src behavior
|
||||
read: __init__.py, config.py, database.py
|
||||
- change src config
|
||||
read: config.py, logging_config.py
|
||||
- explore src subdirectories
|
||||
index: apps/api/src/api/.pi-map.index.md, apps/api/src/auth/.pi-map.index.md, apps/api/src/headquarter_api.egg-info/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,40 @@
|
||||
# apps/api/src/api (index)
|
||||
dir: apps/api/src/api
|
||||
|
||||
## role
|
||||
Defines shared API infrastructure including reusable Pydantic validators for consistent input validation across API endpoints.
|
||||
## parent
|
||||
index: apps/api/src/.pi-map.index.md
|
||||
map: apps/api/src/.pi-map.md
|
||||
## children
|
||||
- apps/api/src/api/config
|
||||
index: apps/api/src/api/config/.pi-map.index.md
|
||||
map: apps/api/src/api/config/.pi-map.md
|
||||
- apps/api/src/api/project
|
||||
index: apps/api/src/api/project/.pi-map.index.md
|
||||
map: apps/api/src/api/project/.pi-map.md
|
||||
- apps/api/src/api/system
|
||||
index: apps/api/src/api/system/.pi-map.index.md
|
||||
map: apps/api/src/api/system/.pi-map.md
|
||||
- apps/api/src/api/tool
|
||||
index: apps/api/src/api/tool/.pi-map.index.md
|
||||
map: apps/api/src/api/tool/.pi-map.md
|
||||
- apps/api/src/api/user
|
||||
index: apps/api/src/api/user/.pi-map.index.md
|
||||
map: apps/api/src/api/user/.pi-map.md
|
||||
- apps/api/src/api/workspace
|
||||
index: apps/api/src/api/workspace/.pi-map.index.md
|
||||
map: apps/api/src/api/workspace/.pi-map.md
|
||||
## files
|
||||
- __init__.py
|
||||
- shared_validators.py
|
||||
## links
|
||||
index: apps/api/src/api/.pi-map.index.md
|
||||
map: apps/api/src/api/.pi-map.md
|
||||
## workflows
|
||||
- change api behavior
|
||||
read: __init__.py, shared_validators.py
|
||||
- explore api subdirectories
|
||||
index: apps/api/src/api/config/.pi-map.index.md, apps/api/src/api/project/.pi-map.index.md, apps/api/src/api/system/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,30 @@
|
||||
# apps/api/src/api
|
||||
dir: apps/api/src/api
|
||||
|
||||
index: apps/api/src/api/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines shared API infrastructure including reusable Pydantic validators for consistent input validation across API endpoints.
|
||||
## files
|
||||
- __init__.py | Marks the directory as a Python package for API routers.
|
||||
- shared_validators.py | Provides reusable Pydantic validator functions for API schema validation including mount paths, files, environment variables, and volume mounts. | exp: func:validate_mount_path(v: str | None) → str | None, call:v.startswith, raise:ValueError, func:validate_files(v: dict | None, max_size_bytes) → dict | None, call:v.items, call:path.startswith, call:len, call:content.encode, raise:ValueError, func:validate_env_vars(v: dict | None) → dict | None, call:isinstance, raise:ValueError, func:validate_volumes(v: list | None) → list | None, call:isinstance, call:enumerate, raise:ValueError
|
||||
## arch
|
||||
Modular utility package with functional validation helpers using Pydantic for declarative schema enforcement.
|
||||
## tags
|
||||
validate, raise:value, error, call:isinstance, mount, api, init, path
|
||||
## symbols
|
||||
- validate_mount_path
|
||||
- validate_files
|
||||
- validate_env_vars
|
||||
- validate_volumes
|
||||
- call:v.startswith
|
||||
- raise:ValueError
|
||||
- call:v.items
|
||||
- call:path.startswith
|
||||
## workflows
|
||||
- change api behavior
|
||||
read: __init__.py, shared_validators.py
|
||||
- explore api subdirectories
|
||||
index: apps/api/src/api/config/.pi-map.index.md, apps/api/src/api/project/.pi-map.index.md, apps/api/src/api/system/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
@@ -1,4 +1 @@
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.users import router as users_router
|
||||
|
||||
__all__ = ["auth_router", "users_router"]
|
||||
"""API routers package."""
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# apps/api/src/api/config (index)
|
||||
dir: apps/api/src/api/config
|
||||
|
||||
## role
|
||||
Provides FastAPI API endpoints for managing user configuration settings and configuration profiles.
|
||||
## parent
|
||||
index: apps/api/src/api/.pi-map.index.md
|
||||
map: apps/api/src/api/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- config_profiles.py
|
||||
- user_config.py
|
||||
## links
|
||||
index: apps/api/src/api/config/.pi-map.index.md
|
||||
map: apps/api/src/api/config/.pi-map.md
|
||||
## workflows
|
||||
- change config behavior
|
||||
read: __init__.py, config_profiles.py, user_config.py
|
||||
- change config config
|
||||
read: config_profiles.py, user_config.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,31 @@
|
||||
# apps/api/src/api/config
|
||||
dir: apps/api/src/api/config
|
||||
|
||||
index: apps/api/src/api/config/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides FastAPI API endpoints for managing user configuration settings and configuration profiles.
|
||||
## files
|
||||
- __init__.py | Aggregates and exports configuration-related API routers for the config module. | dep: src.api.config.config_profiles, src.api.config.user_config
|
||||
- config_profiles.py | FastAPI router providing CRUD endpoints for user config profiles with includes, resolution, defaults, and git URL validation | exp: func:list_config_profiles(project_id, tool_type_id, current_user_id, session), call:select(ConfigProfile) .where(ConfigProfile.user_id == user_uuid) .options, call:selectinload, call:uuid.UUID, call:conditions.append, call:ConfigProfile.project_id.is_, call:ConfigProfile.tool_type_id.is_, call:query.where, call:or_, call:session.execute, call:result.scalars().all, call:profile_to_response, func:create_config_profile(data: ConfigProfileCreate, current_user_id, session), call:create_profile, call:logger.debug, call:profile_to_response, func:get_config_profile(profile_id: str, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:profile_to_response, raise:HTTPException, func:update_config_profile(profile_id: str, data: ConfigProfileUpdate, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:update_profile, call:logger.debug, call:profile_to_response, raise:HTTPException, func:delete_config_profile(profile_id: str, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:session.delete, call:session.commit, call:logger.debug, raise:HTTPException, func:update_profile_includes_endpoint(profile_id: str, data: ConfigProfileIncludeUpdate, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:update_includes, call:session.execute, call:select(ConfigProfileInclude).where, call:inc_result.scalars().all, call:logger.debug, call:profile_to_response, call:list, raise:HTTPException, func:preview_config_profile(profile_id: str, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:resolve_profile, call:resolved_profile_to_dict, raise:HTTPException, func:resolve_default_profile_endpoint(project_id, tool_type_id, current_user_id, session), call:resolve_default_profile, call:uuid.UUID, func:get_default_profiles_endpoint(user_id, session) → dict, call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, func:set_default_profiles_endpoint(data: DefaultProfilesUpdate, user_id, session) → dict, call:validate_default_profiles, call:get_or_create_user_config, call:session.commit, call:session.refresh, func:get_default_profile_for_tool_type_endpoint(tool_type_id: str, user_id, session) → dict, call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:user_config.default_profiles.get, func:validate_git_url_endpoint(data: ValidateGitUrlRequest, current_user_id, session) → ValidateGitUrlResponse, call:validate_git_url | dep: logging, uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, sqlalchemy.orm, src.auth.dependencies, src.models, src.schemas.config, src.services.config.config_profile_resolver, src.services.config.crud_service, src.services.config.resolver_service
|
||||
- user_config.py | Provides FastAPI endpoints to get and update the current user's configuration settings, creating a default config if none exists. | exp: func:_get_or_create_config(session: AsyncSession, user_id: uuid.UUID) → UserConfig, call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:UserConfig, call:session.add, call:session.commit, call:session.refresh, func:get_user_config(user_id, session) → UserConfigResponse, call:_get_user, call:_get_or_create_config, call:UserConfigResponse.model_validate, func:update_user_config(data: UserConfigUpdate, user_id, session) → UserConfigResponse, call:_get_user, call:_get_or_create_config, call:data.model_dump, call:logger.debug, call:session.commit, call:session.refresh, call:UserConfigResponse.model_validate | dep: logging, uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.schemas.user
|
||||
## arch
|
||||
Modular FastAPI router pattern with separate route modules for distinct resource domains (user config vs config profiles), each implementing standard CRUD operations with validation and default initialization logic.
|
||||
## tags
|
||||
config, profile, get, user, includes, call:uuid.uuid, or, default
|
||||
## symbols
|
||||
- list_config_profiles
|
||||
- create_config_profile
|
||||
- get_config_profile
|
||||
- update_config_profile
|
||||
- delete_config_profile
|
||||
- update_profile_includes_endpoint
|
||||
- preview_config_profile
|
||||
- resolve_default_profile_endpoint
|
||||
## workflows
|
||||
- change config behavior
|
||||
read: __init__.py, config_profiles.py, user_config.py
|
||||
- change config config
|
||||
read: config_profiles.py, user_config.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Config API routers module."""
|
||||
|
||||
from src.api.config.config_profiles import router as config_profiles_router
|
||||
from src.api.config.user_config import router as user_config_router
|
||||
|
||||
__all__ = ["config_profiles_router", "user_config_router"]
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Config profile API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
|
||||
from src.schemas.config import (
|
||||
ConfigProfileCreate,
|
||||
ConfigProfileIncludeUpdate,
|
||||
ConfigProfileResponse,
|
||||
ConfigProfileUpdate,
|
||||
DefaultProfilesUpdate,
|
||||
ValidateGitUrlRequest,
|
||||
ValidateGitUrlResponse,
|
||||
)
|
||||
from src.services.config.config_profile_resolver import (
|
||||
ConfigProfileCycleError,
|
||||
resolve_profile,
|
||||
resolved_profile_to_dict,
|
||||
)
|
||||
from src.services.config.crud_service import (
|
||||
create_profile,
|
||||
get_or_create_user_config,
|
||||
get_profile_with_includes,
|
||||
profile_to_response,
|
||||
update_includes,
|
||||
update_profile,
|
||||
validate_default_profiles,
|
||||
)
|
||||
from src.services.config.resolver_service import (
|
||||
resolve_default_profile,
|
||||
validate_git_url,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ConfigProfileResponse])
|
||||
async def list_config_profiles(
|
||||
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||
tool_type_id: str | None = Query(
|
||||
None, description="Filter by tool type compatibility"
|
||||
),
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""List config profiles, optionally filtered by compatibility."""
|
||||
user_uuid = current_user_id
|
||||
query = (
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.user_id == user_uuid)
|
||||
.options(selectinload(ConfigProfile.includes))
|
||||
)
|
||||
|
||||
if project_id or tool_type_id:
|
||||
from sqlalchemy import or_
|
||||
|
||||
project_uuid = uuid.UUID(project_id) if project_id else None
|
||||
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
||||
|
||||
conditions: list = []
|
||||
conditions.append(
|
||||
(ConfigProfile.project_id.is_(None))
|
||||
& (ConfigProfile.tool_type_id.is_(None))
|
||||
)
|
||||
if project_uuid:
|
||||
conditions.append(ConfigProfile.project_id == project_uuid)
|
||||
if tool_uuid:
|
||||
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
||||
if project_uuid and tool_uuid:
|
||||
conditions.append(
|
||||
(ConfigProfile.project_id == project_uuid)
|
||||
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||
)
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
result = await session.execute(query)
|
||||
profiles = result.scalars().all()
|
||||
return [profile_to_response(p) for p in profiles]
|
||||
|
||||
|
||||
@router.post(
|
||||
"", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_config_profile(
|
||||
data: ConfigProfileCreate,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Create a new config profile."""
|
||||
profile = await create_profile(session, current_user_id, data)
|
||||
logger.debug("Created config profile %s for user %s", profile.id, current_user_id)
|
||||
return profile_to_response(profile)
|
||||
|
||||
|
||||
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
||||
async def get_config_profile(
|
||||
profile_id: str,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Get a config profile by ID."""
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
)
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
return profile_to_response(profile)
|
||||
|
||||
|
||||
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||
async def update_config_profile(
|
||||
profile_id: str,
|
||||
data: ConfigProfileUpdate,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Update a config profile."""
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
)
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
profile = await update_profile(session, profile, data)
|
||||
logger.debug("Updated config profile %s", profile.id)
|
||||
return profile_to_response(profile)
|
||||
|
||||
|
||||
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_config_profile(
|
||||
profile_id: str,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Delete a config profile."""
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
)
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
await session.delete(profile)
|
||||
await session.commit()
|
||||
|
||||
logger.debug("Deleted config profile %s", profile_id)
|
||||
return None
|
||||
|
||||
|
||||
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
||||
async def update_profile_includes_endpoint(
|
||||
profile_id: str,
|
||||
data: ConfigProfileIncludeUpdate,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Update the ordered includes for a config profile."""
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
)
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||
profile = await update_includes(session, profile, included_uuids, current_user_id)
|
||||
|
||||
inc_result = await session.execute(
|
||||
select(ConfigProfileInclude).where(
|
||||
ConfigProfileInclude.profile_id == profile.id
|
||||
)
|
||||
)
|
||||
direct_includes = inc_result.scalars().all()
|
||||
|
||||
logger.debug("Updated includes for config profile %s", profile.id)
|
||||
return profile_to_response(profile, list(direct_includes))
|
||||
|
||||
|
||||
@router.get("/{profile_id}/preview")
|
||||
async def preview_config_profile(
|
||||
profile_id: str,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Preview the resolved output of a config profile."""
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
)
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
try:
|
||||
resolved = await resolve_profile(session, profile.id)
|
||||
except ConfigProfileCycleError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
return resolved_profile_to_dict(resolved)
|
||||
|
||||
|
||||
@router.get("/defaults/resolve")
|
||||
async def resolve_default_profile_endpoint(
|
||||
project_id: str = Query(..., description="Project ID"),
|
||||
tool_type_id: str = Query(..., description="Tool type ID"),
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Resolve the default config profile for a project/tool combination."""
|
||||
return await resolve_default_profile(
|
||||
session,
|
||||
current_user_id,
|
||||
uuid.UUID(project_id),
|
||||
uuid.UUID(tool_type_id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/defaults")
|
||||
async def get_default_profiles_endpoint(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get all default profile mappings for the current user."""
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
user_config = result.scalar_one_or_none()
|
||||
return {"default_profiles": user_config.default_profiles if user_config else {}}
|
||||
|
||||
|
||||
@router.put("/defaults")
|
||||
async def set_default_profiles_endpoint(
|
||||
data: DefaultProfilesUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Set default profile mappings for the current user."""
|
||||
await validate_default_profiles(session, user_id, data.default_profiles)
|
||||
user_config = await get_or_create_user_config(session, user_id)
|
||||
user_config.config = {
|
||||
**user_config.config,
|
||||
"default_profiles": data.default_profiles,
|
||||
}
|
||||
await session.commit()
|
||||
await session.refresh(user_config)
|
||||
return {"default_profiles": user_config.default_profiles}
|
||||
|
||||
|
||||
@router.get("/defaults/{tool_type_id}")
|
||||
async def get_default_profile_for_tool_type_endpoint(
|
||||
tool_type_id: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get the default profile ID for a specific tool type."""
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
user_config = result.scalar_one_or_none()
|
||||
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
|
||||
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
||||
|
||||
|
||||
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
||||
async def validate_git_url_endpoint(
|
||||
data: ValidateGitUrlRequest,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ValidateGitUrlResponse:
|
||||
"""Validate a git remote URL and list available branches."""
|
||||
return await validate_git_url(session, current_user_id, data.url, data.ssh_key_id)
|
||||
@@ -2,19 +2,21 @@ import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.models.user_config import UserConfig
|
||||
from src.models import UserConfig
|
||||
from src.schemas.user import UserConfigResponse, UserConfigUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||
|
||||
|
||||
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
||||
async def _get_or_create_config(
|
||||
session: AsyncSession, user_id: uuid.UUID
|
||||
) -> UserConfig:
|
||||
"""Get or create user config record.
|
||||
|
||||
Args:
|
||||
@@ -24,7 +26,9 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
|
||||
Returns:
|
||||
The user's config, creating a new one if it doesn't exist.
|
||||
"""
|
||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
config = UserConfig(user_id=user_id, config={})
|
||||
@@ -34,24 +38,6 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
|
||||
return config
|
||||
|
||||
|
||||
class UserConfigResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
default_editor: str | None = None
|
||||
theme: str = "system"
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
last_session_id: str | None = None
|
||||
|
||||
|
||||
class UserConfigUpdate(BaseModel):
|
||||
default_editor: str | None = None
|
||||
theme: str | None = None
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
last_session_id: str | None = None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/config",
|
||||
response_model=UserConfigResponse,
|
||||
@@ -1,337 +0,0 @@
|
||||
"""Config folder API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.shared_validators import validate_files as _validate_files, validate_mount_path as _validate_mount_path
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.config_folder import ConfigFolder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
||||
|
||||
|
||||
class ConfigFolderCreate(BaseModel):
|
||||
name: str = Field(description="Folder name (unique per user)")
|
||||
description: str | None = Field(default=None, description="Optional description")
|
||||
mount_path: str = Field(description="Default mount path in container")
|
||||
files: dict = Field(default_factory=dict, description="Files as {path: content}")
|
||||
|
||||
@field_validator("mount_path")
|
||||
@classmethod
|
||||
def validate_mount_path(cls, v: str) -> str:
|
||||
return _validate_mount_path(v)
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict) -> dict:
|
||||
return _validate_files(v)
|
||||
|
||||
|
||||
class ConfigFolderUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, description="Folder name")
|
||||
description: str | None = Field(default=None, description="Optional description")
|
||||
mount_path: str | None = Field(default=None, description="Default mount path")
|
||||
files: dict | None = Field(default=None, description="Files as {path: content}")
|
||||
is_active: bool | None = Field(default=None, description="Active/inactive toggle")
|
||||
|
||||
@field_validator("mount_path")
|
||||
@classmethod
|
||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||
return _validate_mount_path(v)
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict | None) -> dict | None:
|
||||
return _validate_files(v)
|
||||
|
||||
|
||||
class ProjectOverrideCreate(BaseModel):
|
||||
mount_path: str | None = Field(default=None, description="Override mount path")
|
||||
files: dict = Field(default_factory=dict, description="Override files")
|
||||
|
||||
@field_validator("mount_path")
|
||||
@classmethod
|
||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||
return _validate_mount_path(v)
|
||||
|
||||
|
||||
class ConfigFolderResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: str | None
|
||||
mount_path: str
|
||||
files: dict
|
||||
project_overrides: dict | None
|
||||
is_active: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
|
||||
async def list_config_folders(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""List config folders for the current user."""
|
||||
query = select(ConfigFolder).where(ConfigFolder.user_id == user_id)
|
||||
result = await session.execute(query)
|
||||
folders = result.scalars().all()
|
||||
|
||||
return {
|
||||
"folders": [
|
||||
{
|
||||
"id": str(f.id),
|
||||
"user_id": str(f.user_id),
|
||||
"name": f.name,
|
||||
"description": f.description,
|
||||
"mount_path": f.mount_path,
|
||||
"files": f.files,
|
||||
"project_overrides": f.project_overrides,
|
||||
"is_active": f.is_active,
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
for f in folders
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED)
|
||||
async def create_config_folder(
|
||||
data: ConfigFolderCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Create a config folder."""
|
||||
# Check for duplicate name
|
||||
existing = await session.scalar(
|
||||
select(ConfigFolder).where(
|
||||
ConfigFolder.user_id == user_id,
|
||||
ConfigFolder.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"config folder with name '{data.name}' already exists"
|
||||
)
|
||||
|
||||
folder = ConfigFolder(
|
||||
user_id=user_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
mount_path=data.mount_path,
|
||||
files=data.files,
|
||||
)
|
||||
session.add(folder)
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"user_id": str(folder.user_id),
|
||||
"name": folder.name,
|
||||
"description": folder.description,
|
||||
"mount_path": folder.mount_path,
|
||||
"files": folder.files,
|
||||
"project_overrides": folder.project_overrides,
|
||||
"is_active": folder.is_active,
|
||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{folder_id}", summary="Update config folder", description="Update an existing config folder.")
|
||||
async def update_config_folder(
|
||||
folder_id: uuid.UUID,
|
||||
data: ConfigFolderUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Update a config folder."""
|
||||
folder = await session.get(ConfigFolder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
if data.name is not None:
|
||||
folder.name = data.name
|
||||
if data.description is not None:
|
||||
folder.description = data.description
|
||||
if data.mount_path is not None:
|
||||
folder.mount_path = data.mount_path
|
||||
if data.files is not None:
|
||||
folder.files = data.files
|
||||
if data.is_active is not None:
|
||||
folder.is_active = data.is_active
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"user_id": str(folder.user_id),
|
||||
"name": folder.name,
|
||||
"description": folder.description,
|
||||
"mount_path": folder.mount_path,
|
||||
"files": folder.files,
|
||||
"project_overrides": folder.project_overrides,
|
||||
"is_active": folder.is_active,
|
||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_config_folder(
|
||||
folder_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Delete a config folder."""
|
||||
folder = await session.get(ConfigFolder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
await session.delete(folder)
|
||||
await session.commit()
|
||||
|
||||
|
||||
class ProjectOverrideWithId(ProjectOverrideCreate):
|
||||
project_id: uuid.UUID = Field(description="Project ID for the override")
|
||||
|
||||
|
||||
@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.")
|
||||
async def get_config_folder(
|
||||
folder_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get a config folder by ID."""
|
||||
folder = await session.get(ConfigFolder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"user_id": str(folder.user_id),
|
||||
"name": folder.name,
|
||||
"description": folder.description,
|
||||
"mount_path": folder.mount_path,
|
||||
"files": folder.files,
|
||||
"project_overrides": folder.project_overrides,
|
||||
"is_active": folder.is_active,
|
||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.")
|
||||
async def add_project_override(
|
||||
folder_id: uuid.UUID,
|
||||
data: ProjectOverrideWithId,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Add a project override to a config folder."""
|
||||
folder = await session.get(ConfigFolder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
# Initialize project_overrides if None
|
||||
if folder.project_overrides is None:
|
||||
folder.project_overrides = {}
|
||||
|
||||
# Add/update override
|
||||
override_data = {}
|
||||
if data.mount_path is not None:
|
||||
override_data["mount_path"] = data.mount_path
|
||||
if data.files is not None:
|
||||
override_data["files"] = data.files
|
||||
|
||||
# Use a copy to trigger SQLAlchemy change detection on JSONB
|
||||
current_overrides = dict(folder.project_overrides or {})
|
||||
current_overrides[str(data.project_id)] = override_data
|
||||
folder.project_overrides = current_overrides
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"project_overrides": folder.project_overrides,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{folder_id}/overrides/{project_id}", summary="Update project override", description="Update a project override.")
|
||||
async def update_project_override(
|
||||
folder_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
data: ProjectOverrideCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Update a project override."""
|
||||
folder = await session.get(ConfigFolder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
# Initialize project_overrides if None
|
||||
if folder.project_overrides is None:
|
||||
folder.project_overrides = {}
|
||||
|
||||
# Update override
|
||||
current_overrides = dict(folder.project_overrides or {})
|
||||
override_data = current_overrides.get(str(project_id), {})
|
||||
if data.mount_path is not None:
|
||||
override_data["mount_path"] = data.mount_path
|
||||
if data.files is not None:
|
||||
override_data["files"] = data.files
|
||||
|
||||
current_overrides[str(project_id)] = override_data
|
||||
folder.project_overrides = current_overrides
|
||||
|
||||
# Mark the field as modified to ensure SQLAlchemy detects the change
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
flag_modified(folder, "project_overrides")
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"project_overrides": folder.project_overrides,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{folder_id}/overrides/{project_id}", summary="Remove project override", description="Remove a project override.")
|
||||
async def remove_project_override(
|
||||
folder_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Remove a project override."""
|
||||
folder = await session.get(ConfigFolder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
# Remove override if exists
|
||||
current_overrides = dict(folder.project_overrides or {})
|
||||
if str(project_id) in current_overrides:
|
||||
del current_overrides[str(project_id)]
|
||||
folder.project_overrides = current_overrides
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"project_overrides": folder.project_overrides or {},
|
||||
}
|
||||
@@ -1,723 +0,0 @@
|
||||
"""Config profile API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.api.shared_validators import validate_env_vars as _validate_env_vars
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.project import Project
|
||||
from src.models.tool_type import ToolType
|
||||
from src.services.config_profile_resolver import (
|
||||
ConfigProfileCycleError,
|
||||
check_include_cycle,
|
||||
resolve_profile,
|
||||
resolved_profile_to_dict,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||
|
||||
MAX_PROFILE_SIZE_MB = 10
|
||||
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
|
||||
|
||||
|
||||
def _validate_uuid(v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
try:
|
||||
uuid.UUID(v)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid UUID: {v}")
|
||||
return v
|
||||
|
||||
|
||||
def _calculate_profile_size(data: dict) -> int:
|
||||
"""Calculate approximate serialized size of profile data."""
|
||||
total = 0
|
||||
for key, value in data.get("env_vars", {}).items():
|
||||
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||
for key, value in data.get("runtime_hints", {}).items():
|
||||
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||
for mount in data.get("mounts", []):
|
||||
total += len(str(mount.get("target", "")).encode("utf-8"))
|
||||
total += len(str(mount.get("mode", "")).encode("utf-8"))
|
||||
for path, content in mount.get("files", {}).items():
|
||||
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||
for path, content in data.get("files", {}).items():
|
||||
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||
return total
|
||||
|
||||
|
||||
class GitMountItem(BaseModel):
|
||||
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
|
||||
target_path: str = Field(description="Absolute path inside container")
|
||||
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||
|
||||
@field_validator("remote_url")
|
||||
@classmethod
|
||||
def validate_remote_url(cls, v: str) -> str:
|
||||
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
|
||||
return v
|
||||
|
||||
@field_validator("source_path")
|
||||
@classmethod
|
||||
def validate_source_path(cls, v: str) -> str:
|
||||
if v.startswith("/"):
|
||||
raise ValueError("source_path must be relative (no leading /)")
|
||||
if ".." in v:
|
||||
raise ValueError("source_path cannot contain path traversal (..)")
|
||||
return v
|
||||
|
||||
@field_validator("target_path")
|
||||
@classmethod
|
||||
def validate_target_path(cls, v: str) -> str:
|
||||
if ".." in v:
|
||||
raise ValueError("target_path cannot contain path traversal (..)")
|
||||
return v
|
||||
|
||||
|
||||
class MountItem(BaseModel):
|
||||
target: str = Field(description="Absolute mount target path")
|
||||
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
||||
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
||||
|
||||
@field_validator("target")
|
||||
@classmethod
|
||||
def validate_target(cls, v: str) -> str:
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Mount target must be absolute (start with /)")
|
||||
return v
|
||||
|
||||
@field_validator("mode")
|
||||
@classmethod
|
||||
def validate_mode(cls, v: str) -> str:
|
||||
if v not in ("ro", "rw"):
|
||||
raise ValueError("Mount mode must be 'ro' or 'rw'")
|
||||
return v
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict) -> dict:
|
||||
for path in v.keys():
|
||||
if ".." in path or not path:
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
if path.startswith("/"):
|
||||
raise ValueError(
|
||||
f"Mount file paths must be relative (got: {path}). "
|
||||
f"The mount target defines the absolute container path."
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileCreate(BaseModel):
|
||||
name: str = Field(description="Profile name (unique per user)")
|
||||
description: str | None = Field(default=None, description="Optional description")
|
||||
project_id: str | None = Field(default=None, description="Optional project ID")
|
||||
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||
env_vars: dict = Field(default_factory=dict, description="Environment variables")
|
||||
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
||||
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
|
||||
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
||||
git_mounts: list[GitMountItem] = Field(default_factory=list, description="Git repository mounts")
|
||||
is_default: bool = Field(default=False, description="Whether this is the default profile for its scope")
|
||||
|
||||
@field_validator("project_id", "tool_type_id")
|
||||
@classmethod
|
||||
def validate_uuids(cls, v: str | None) -> str | None:
|
||||
return _validate_uuid(v)
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict) -> dict:
|
||||
for path in v.keys():
|
||||
if ".." in path or not path:
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
if path.startswith("/"):
|
||||
raise ValueError(
|
||||
f"File paths must be relative (got: {path}). "
|
||||
f"Use Mounts for absolute container paths."
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("env_vars")
|
||||
@classmethod
|
||||
def validate_env_vars(cls, v: dict) -> dict:
|
||||
result = _validate_env_vars(v)
|
||||
if result is None:
|
||||
raise ValueError("env_vars must be a JSON object")
|
||||
return result
|
||||
|
||||
@field_validator("runtime_hints")
|
||||
@classmethod
|
||||
def validate_runtime_hints(cls, v: dict) -> dict:
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError("runtime_hints must be a JSON object")
|
||||
return v
|
||||
|
||||
@field_validator("mounts")
|
||||
@classmethod
|
||||
def validate_mounts(cls, v: list) -> list:
|
||||
if not isinstance(v, list):
|
||||
raise ValueError("mounts must be a JSON array")
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, description="Profile name")
|
||||
description: str | None = Field(default=None, description="Optional description")
|
||||
project_id: str | None = Field(default=None, description="Optional project ID")
|
||||
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||
env_vars: dict | None = Field(default=None, description="Environment variables")
|
||||
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
||||
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
|
||||
files: dict | None = Field(default=None, description="Files as {relative_path: content}")
|
||||
git_mounts: list[GitMountItem] | None = Field(default=None, description="Git repository mounts")
|
||||
is_default: bool | None = Field(default=None, description="Whether this is the default profile")
|
||||
|
||||
@field_validator("project_id", "tool_type_id")
|
||||
@classmethod
|
||||
def validate_uuids(cls, v: str | None) -> str | None:
|
||||
return _validate_uuid(v)
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
for path in v.keys():
|
||||
if ".." in path or path.startswith("/") or not path:
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileIncludeUpdate(BaseModel):
|
||||
includes: list[str] = Field(description="Ordered list of included profile IDs")
|
||||
|
||||
@field_validator("includes")
|
||||
@classmethod
|
||||
def validate_includes(cls, v: list) -> list:
|
||||
for item in v:
|
||||
try:
|
||||
uuid.UUID(item)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid UUID in includes: {item}")
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: str | None
|
||||
project_id: str | None
|
||||
tool_type_id: str | None
|
||||
env_vars: dict
|
||||
runtime_hints: dict
|
||||
mounts: list
|
||||
files: dict
|
||||
git_mounts: list
|
||||
is_default: bool
|
||||
includes: list[dict]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
async def _get_profile_with_includes(session: AsyncSession, profile_id: uuid.UUID) -> ConfigProfile | None:
|
||||
"""Fetch a profile with includes eagerly loaded."""
|
||||
result = await session.execute(
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.id == profile_id)
|
||||
.options(selectinload(ConfigProfile.includes))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _check_access(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID | None = None,
|
||||
tool_type_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Verify user has access to referenced project and tool type."""
|
||||
if project_id is not None:
|
||||
project = await session.get(Project, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
# Add ownership check if needed; for now just verify existence
|
||||
if tool_type_id is not None:
|
||||
tool_type = await session.get(ToolType, tool_type_id)
|
||||
if tool_type is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
|
||||
|
||||
|
||||
async def _validate_git_mounts(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
git_mounts: list[dict],
|
||||
project_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Validate git mount URLs.
|
||||
|
||||
Simply checks that remote_url looks like a valid git URL.
|
||||
Actual clone validation happens at instance startup time.
|
||||
"""
|
||||
for mount in git_mounts:
|
||||
remote_url = mount.get("remote_url")
|
||||
if not remote_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Git mount missing remote_url",
|
||||
)
|
||||
|
||||
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid git URL: {remote_url}",
|
||||
)
|
||||
|
||||
|
||||
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
|
||||
return {
|
||||
"id": str(profile.id),
|
||||
"user_id": str(profile.user_id),
|
||||
"name": profile.name,
|
||||
"description": profile.description,
|
||||
"project_id": str(profile.project_id) if profile.project_id else None,
|
||||
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
|
||||
"env_vars": profile.env_vars or {},
|
||||
"runtime_hints": profile.runtime_hints or {},
|
||||
"mounts": profile.mounts or [],
|
||||
"git_mounts": profile.git_mounts or [],
|
||||
"files": profile.files or {},
|
||||
"is_default": profile.is_default,
|
||||
"includes": [
|
||||
{
|
||||
"id": str(inc.id),
|
||||
"included_profile_id": str(inc.included_profile_id),
|
||||
"order_index": inc.order_index,
|
||||
}
|
||||
for inc in (includes or profile.includes)
|
||||
],
|
||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[ConfigProfileResponse])
|
||||
async def list_config_profiles(
|
||||
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||
tool_type_id: str | None = Query(None, description="Filter by tool type compatibility"),
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""List config profiles, optionally filtered by compatibility."""
|
||||
user_uuid = current_user_id
|
||||
query = select(ConfigProfile).where(ConfigProfile.user_id == user_uuid).options(selectinload(ConfigProfile.includes))
|
||||
|
||||
if project_id or tool_type_id:
|
||||
# Compatibility filter: include portable profiles and matching scoped profiles
|
||||
project_uuid = uuid.UUID(project_id) if project_id else None
|
||||
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
conditions: list = []
|
||||
# Portable profiles (no project, no tool)
|
||||
conditions.append(
|
||||
(ConfigProfile.project_id.is_(None)) & (ConfigProfile.tool_type_id.is_(None))
|
||||
)
|
||||
if project_uuid:
|
||||
# Profiles matching this project (with or without tool)
|
||||
conditions.append(ConfigProfile.project_id == project_uuid)
|
||||
if tool_uuid:
|
||||
# Profiles matching this tool (with or without project)
|
||||
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
||||
if project_uuid and tool_uuid:
|
||||
# Exact match
|
||||
conditions.append(
|
||||
(ConfigProfile.project_id == project_uuid) & (ConfigProfile.tool_type_id == tool_uuid)
|
||||
)
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
result = await session.execute(query)
|
||||
profiles = result.scalars().all()
|
||||
return [_profile_to_response(p) for p in profiles]
|
||||
|
||||
|
||||
@router.post("", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_config_profile(
|
||||
data: ConfigProfileCreate,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Create a new config profile."""
|
||||
user_uuid = current_user_id
|
||||
|
||||
# Check for duplicate name
|
||||
existing = await session.execute(
|
||||
select(ConfigProfile).where(
|
||||
ConfigProfile.user_id == user_uuid,
|
||||
ConfigProfile.name == data.name,
|
||||
).options(selectinload(ConfigProfile.includes))
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Profile with name '{data.name}' already exists",
|
||||
)
|
||||
|
||||
# Validate references
|
||||
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
||||
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
|
||||
await _check_access(session, user_uuid, project_uuid, tool_uuid)
|
||||
|
||||
# Validate git mounts reference existing repositories
|
||||
if data.git_mounts:
|
||||
git_mounts_data = [m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts]
|
||||
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
|
||||
|
||||
# Check size
|
||||
size = _calculate_profile_size(data.model_dump())
|
||||
if size > MAX_PROFILE_SIZE_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
||||
)
|
||||
|
||||
profile = ConfigProfile(
|
||||
user_id=user_uuid,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
project_id=project_uuid,
|
||||
tool_type_id=tool_uuid,
|
||||
env_vars=data.env_vars,
|
||||
runtime_hints=data.runtime_hints,
|
||||
mounts=[m.model_dump() for m in data.mounts],
|
||||
git_mounts=[m.model_dump() for m in data.git_mounts],
|
||||
files=data.files,
|
||||
is_default=data.is_default,
|
||||
)
|
||||
session.add(profile)
|
||||
await session.commit()
|
||||
|
||||
# Re-fetch with includes to avoid lazy loading issues
|
||||
result = await session.execute(
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.id == profile.id)
|
||||
.options(selectinload(ConfigProfile.includes))
|
||||
)
|
||||
profile = result.scalar_one()
|
||||
|
||||
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
|
||||
return _profile_to_response(profile)
|
||||
|
||||
|
||||
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
||||
async def get_config_profile(
|
||||
profile_id: str,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Get a config profile by ID."""
|
||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||
return _profile_to_response(profile)
|
||||
|
||||
|
||||
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||
async def update_config_profile(
|
||||
profile_id: str,
|
||||
data: ConfigProfileUpdate,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Update a config profile."""
|
||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle name uniqueness
|
||||
if "name" in update_data:
|
||||
existing = await session.execute(
|
||||
select(ConfigProfile).where(
|
||||
ConfigProfile.user_id == profile.user_id,
|
||||
ConfigProfile.name == update_data["name"],
|
||||
ConfigProfile.id != profile.id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Profile with name '{update_data['name']}' already exists",
|
||||
)
|
||||
|
||||
# Validate references
|
||||
project_uuid = (
|
||||
uuid.UUID(update_data["project_id"])
|
||||
if "project_id" in update_data and update_data["project_id"]
|
||||
else (profile.project_id if "project_id" not in update_data else None)
|
||||
)
|
||||
tool_uuid = (
|
||||
uuid.UUID(update_data["tool_type_id"])
|
||||
if "tool_type_id" in update_data and update_data["tool_type_id"]
|
||||
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
||||
)
|
||||
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
|
||||
|
||||
# Validate git mounts reference existing repositories
|
||||
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
|
||||
git_mounts_data = [
|
||||
m.model_dump() if hasattr(m, "model_dump") else m
|
||||
for m in update_data["git_mounts"]
|
||||
]
|
||||
await _validate_git_mounts(session, profile.user_id, git_mounts_data, project_uuid)
|
||||
|
||||
# Check size
|
||||
current_data = _profile_to_response(profile)
|
||||
merged = {**current_data, **update_data}
|
||||
size = _calculate_profile_size(merged)
|
||||
if size > MAX_PROFILE_SIZE_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
||||
)
|
||||
|
||||
# Apply updates
|
||||
for field_name, value in update_data.items():
|
||||
if field_name in ("project_id", "tool_type_id"):
|
||||
value = uuid.UUID(value) if value else None
|
||||
elif field_name == "mounts" and value is not None:
|
||||
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||
elif field_name == "git_mounts" and value is not None:
|
||||
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||
setattr(profile, field_name, value)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Re-fetch with includes to avoid lazy loading issues
|
||||
result = await session.execute(
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.id == profile.id)
|
||||
.options(selectinload(ConfigProfile.includes))
|
||||
)
|
||||
profile = result.scalar_one()
|
||||
|
||||
logger.debug("Updated config profile %s", profile.id)
|
||||
return _profile_to_response(profile)
|
||||
|
||||
|
||||
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_config_profile(
|
||||
profile_id: str,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Delete a config profile."""
|
||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||
|
||||
await session.delete(profile)
|
||||
await session.commit()
|
||||
|
||||
logger.debug("Deleted config profile %s", profile_id)
|
||||
return None
|
||||
|
||||
|
||||
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
||||
async def update_profile_includes(
|
||||
profile_id: str,
|
||||
data: ConfigProfileIncludeUpdate,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Update the ordered includes for a config profile."""
|
||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||
|
||||
# Validate all included profiles exist and belong to the user
|
||||
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||
for inc_uuid in included_uuids:
|
||||
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
||||
if inc_profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Included profile not found: {inc_uuid}",
|
||||
)
|
||||
if inc_profile.user_id != current_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Not authorized to include profile: {inc_uuid}",
|
||||
)
|
||||
if inc_uuid == profile.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Profile cannot include itself",
|
||||
)
|
||||
|
||||
# Check for cycles
|
||||
cycle = await check_include_cycle(session, profile.id, None)
|
||||
if cycle is None and included_uuids:
|
||||
# Check each new include would not create a cycle
|
||||
for inc_uuid in included_uuids:
|
||||
cycle = await check_include_cycle(session, profile.id, inc_uuid)
|
||||
if cycle is not None:
|
||||
break
|
||||
|
||||
if cycle is not None:
|
||||
cycle_str = " -> ".join(str(c) for c in cycle)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Include cycle detected: {cycle_str}",
|
||||
)
|
||||
|
||||
# Remove existing includes
|
||||
result = await session.execute(
|
||||
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
|
||||
)
|
||||
for existing in result.scalars().all():
|
||||
await session.delete(existing)
|
||||
await session.flush()
|
||||
|
||||
# Add new includes
|
||||
for order_index, inc_uuid in enumerate(included_uuids):
|
||||
include = ConfigProfileInclude(
|
||||
profile_id=profile.id,
|
||||
included_profile_id=inc_uuid,
|
||||
order_index=order_index,
|
||||
)
|
||||
session.add(include)
|
||||
await session.flush()
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Re-fetch profile (includes loaded separately due to SQLite async issue)
|
||||
result = await session.execute(
|
||||
select(ConfigProfile).where(ConfigProfile.id == profile.id)
|
||||
)
|
||||
profile = result.scalar_one()
|
||||
|
||||
inc_result = await session.execute(
|
||||
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
|
||||
)
|
||||
direct_includes = inc_result.scalars().all()
|
||||
|
||||
logger.debug("Updated includes for config profile %s", profile.id)
|
||||
return _profile_to_response(profile, list(direct_includes))
|
||||
|
||||
|
||||
@router.get("/{profile_id}/preview")
|
||||
async def preview_config_profile(
|
||||
profile_id: str,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Preview the resolved output of a config profile."""
|
||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||
|
||||
try:
|
||||
resolved = await resolve_profile(session, profile.id)
|
||||
except ConfigProfileCycleError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
return resolved_profile_to_dict(resolved)
|
||||
|
||||
|
||||
@router.get("/defaults/resolve")
|
||||
async def resolve_default_profile(
|
||||
project_id: str = Query(..., description="Project ID"),
|
||||
tool_type_id: str = Query(..., description="Tool type ID"),
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Resolve the default config profile for a project/tool combination.
|
||||
|
||||
Selects by specificity:
|
||||
1. project+tool explicit default
|
||||
2. project explicit default
|
||||
3. tool explicit default
|
||||
4. global/user explicit default
|
||||
5. first created compatible profile
|
||||
6. none (returns null)
|
||||
"""
|
||||
user_uuid = current_user_id
|
||||
project_uuid = uuid.UUID(project_id)
|
||||
tool_uuid = uuid.UUID(tool_type_id)
|
||||
|
||||
# Fetch all compatible profiles ordered by created_at
|
||||
query = (
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.user_id == user_uuid)
|
||||
.where(
|
||||
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
|
||||
| (ConfigProfile.project_id == project_uuid)
|
||||
| (ConfigProfile.tool_type_id == tool_uuid)
|
||||
| (
|
||||
(ConfigProfile.project_id == project_uuid)
|
||||
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||
)
|
||||
)
|
||||
.order_by(ConfigProfile.created_at)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
profiles = result.scalars().all()
|
||||
|
||||
if not profiles:
|
||||
return {"profile_id": None, "profile_name": None}
|
||||
|
||||
# Check explicit defaults by specificity
|
||||
explicit_defaults = [p for p in profiles if p.is_default]
|
||||
|
||||
# Most specific: project+tool
|
||||
for p in explicit_defaults:
|
||||
if p.project_id == project_uuid and p.tool_type_id == tool_uuid:
|
||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||
|
||||
# Next: project only
|
||||
for p in explicit_defaults:
|
||||
if p.project_id == project_uuid and p.tool_type_id is None:
|
||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||
|
||||
# Next: tool only
|
||||
for p in explicit_defaults:
|
||||
if p.project_id is None and p.tool_type_id == tool_uuid:
|
||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||
|
||||
# Next: global/user (no project, no tool)
|
||||
for p in explicit_defaults:
|
||||
if p.project_id is None and p.tool_type_id is None:
|
||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||
|
||||
# Fall back to first created compatible profile
|
||||
first = profiles[0]
|
||||
return {"profile_id": str(first.id), "profile_name": first.name}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
# apps/api/src/api/project (index)
|
||||
dir: apps/api/src/api/project
|
||||
|
||||
## role
|
||||
Provides FastAPI REST API endpoints for project and Git repository management, serving as the HTTP interface layer for the project's core domain operations.
|
||||
## parent
|
||||
index: apps/api/src/api/.pi-map.index.md
|
||||
map: apps/api/src/api/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- git_repositories.py
|
||||
- projects.py
|
||||
## links
|
||||
index: apps/api/src/api/project/.pi-map.index.md
|
||||
map: apps/api/src/api/project/.pi-map.md
|
||||
## workflows
|
||||
- change project behavior
|
||||
read: __init__.py, git_repositories.py, projects.py
|
||||
## dirty
|
||||
-
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
"""Project API routers module."""
|
||||
|
||||
from src.api.project.git_repositories import router as git_repositories_router
|
||||
from src.api.project.projects import router as projects_router
|
||||
|
||||
__all__ = ["git_repositories_router", "projects_router"]
|
||||
@@ -0,0 +1,500 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import (
|
||||
_get_owned_project,
|
||||
_get_user,
|
||||
get_current_user_id,
|
||||
get_db_session,
|
||||
)
|
||||
from src.models import GitRepository, SSHKey
|
||||
from src.schemas.project.git_repository import (
|
||||
BranchCreateRequest,
|
||||
BranchesResponse,
|
||||
CheckoutRequest,
|
||||
CommitRequest,
|
||||
CommitResponse,
|
||||
FetchResponse,
|
||||
FileContentResponse,
|
||||
FileListResponse,
|
||||
FileUpdateRequest,
|
||||
FileUpdateResponse,
|
||||
GitRepositoryCreate,
|
||||
GitRepositoryResponse,
|
||||
MergeRequest,
|
||||
MergeResponse,
|
||||
PullResponse,
|
||||
PushResponse,
|
||||
StatusResponse,
|
||||
UpdateSSHKeyRequest,
|
||||
URLParseRequest,
|
||||
URLParseResponse,
|
||||
)
|
||||
from src.services.git.operations import (
|
||||
clone_working_repository,
|
||||
get_repo_path,
|
||||
init_working_repository,
|
||||
list_remote_branches,
|
||||
preflight_remote_repository,
|
||||
)
|
||||
from src.utils.git_control import (
|
||||
checkout_branch,
|
||||
commit_changes,
|
||||
create_branch,
|
||||
delete_branch,
|
||||
fetch,
|
||||
get_status,
|
||||
merge,
|
||||
pull,
|
||||
push,
|
||||
)
|
||||
from src.utils.git_files import commit_file, get_file_content, list_branches, list_tree
|
||||
from src.utils.git_history import get_commit_detail, get_commit_history
|
||||
from src.utils.git_url_parser import parse_git_url
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_repo(
|
||||
session: AsyncSession, user_id: uuid.UUID, project_id: uuid.UUID, repo_id: uuid.UUID
|
||||
) -> GitRepository:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
return repo
|
||||
|
||||
|
||||
async def _get_repo_on_disk(
|
||||
session: AsyncSession, user_id: uuid.UUID, project_id: uuid.UUID, repo_id: uuid.UUID
|
||||
) -> GitRepository:
|
||||
repo = await _get_repo(session, user_id, project_id, repo_id)
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
return repo
|
||||
|
||||
|
||||
def _parse_remote_url(remote_url: str | None, force_original: bool) -> str | None:
|
||||
if not remote_url or force_original:
|
||||
return remote_url
|
||||
parse_result = parse_git_url(remote_url)
|
||||
if parse_result["needs_parsing"] and parse_result["base_url"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"message": "The provided URL appears to be a browser URL, not a git clone URL",
|
||||
"suggested_url": parse_result["base_url"],
|
||||
"original_url": remote_url,
|
||||
"error_code": "URL_NEEDS_PARSING",
|
||||
},
|
||||
)
|
||||
return parse_result.get("base_url") or remote_url
|
||||
|
||||
|
||||
async def _commit_author(session: AsyncSession, user_id: uuid.UUID) -> tuple[str, str]:
|
||||
user = await _get_user(session, user_id)
|
||||
return user.name or "Unknown", user.email or "unknown@example.com"
|
||||
|
||||
|
||||
@router.get("/repositories", response_model=list[GitRepositoryResponse])
|
||||
async def list_user_repositories(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[GitRepository]:
|
||||
"""List all repositories owned by the user."""
|
||||
result = await session.execute(select(GitRepository).where(GitRepository.owner_id == user_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/repositories/parse-url", response_model=URLParseResponse)
|
||||
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
||||
"""Parse a git URL and detect if it's a browser URL that needs correction."""
|
||||
return URLParseResponse(**parse_git_url(data.url))
|
||||
|
||||
|
||||
@router.post("/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_external_repository(
|
||||
data: GitRepositoryCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> GitRepository:
|
||||
"""Create a new external git repository (not tied to any project)."""
|
||||
_user = await _get_user(session, user_id)
|
||||
existing = await session.execute(
|
||||
select(GitRepository).where(
|
||||
GitRepository.project_id.is_(None),
|
||||
GitRepository.owner_id == user_id,
|
||||
GitRepository.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||
remote_url = _parse_remote_url(data.remote_url, data.force_original_url)
|
||||
ssh_key_id, ssh_key = None, None
|
||||
if data.ssh_key_id:
|
||||
try:
|
||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||
if ssh_key is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
if ssh_key.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
|
||||
if remote_url:
|
||||
preflight_remote_repository(remote_url, ssh_key)
|
||||
repo = GitRepository(name=data.name, path="", project_id=None, owner_id=user_id, remote_url=remote_url, ssh_key_id=ssh_key_id)
|
||||
session.add(repo)
|
||||
await session.flush()
|
||||
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
|
||||
repo.path = repo_path
|
||||
if remote_url:
|
||||
try:
|
||||
clone_working_repository(remote_url, repo_path, ssh_key)
|
||||
repo.is_mirror = False
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
|
||||
else:
|
||||
init_working_repository(repo_path)
|
||||
repo.is_mirror = False
|
||||
await session.commit()
|
||||
return repo
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse])
|
||||
async def list_repositories(
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[GitRepository]:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_repository(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
repo = await _get_repo(session, user_id, project_id, repo_id)
|
||||
if os.path.exists(repo.path):
|
||||
shutil.rmtree(repo.path)
|
||||
await session.delete(repo)
|
||||
await session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_repository(
|
||||
project_id: uuid.UUID,
|
||||
data: GitRepositoryCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> GitRepository:
|
||||
"""Create a new git repository in a project."""
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
existing = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project_id, GitRepository.name == data.name)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||
remote_url = _parse_remote_url(data.remote_url, data.force_original_url)
|
||||
ssh_key_id, ssh_key = None, None
|
||||
if data.ssh_key_id:
|
||||
try:
|
||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||
if ssh_key is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
||||
if remote_url:
|
||||
preflight_remote_repository(remote_url, ssh_key)
|
||||
repo_path = get_repo_path(user_id, project_id, data.name)
|
||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||
if remote_url:
|
||||
clone_working_repository(remote_url, repo_path, ssh_key)
|
||||
else:
|
||||
init_working_repository(repo_path)
|
||||
repo = GitRepository(
|
||||
name=data.name, path=repo_path, project_id=project_id, owner_id=user_id,
|
||||
is_mirror=False, remote_url=remote_url, ssh_key_id=ssh_key_id,
|
||||
)
|
||||
session.add(repo)
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
|
||||
|
||||
@router.patch("/{project_id}/repositories/{repo_id}/ssh-key", response_model=GitRepositoryResponse)
|
||||
async def update_repository_ssh_key(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, data: UpdateSSHKeyRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> GitRepository:
|
||||
repo = await _get_repo(session, user_id, project_id, repo_id)
|
||||
if data.ssh_key_id:
|
||||
try:
|
||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||
if ssh_key is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
||||
repo.ssh_key_id = ssh_key_id
|
||||
else:
|
||||
repo.ssh_key_id = None
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories/{repo_id}/history")
|
||||
async def get_repository_history(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID,
|
||||
view: str = "graph", branch: str | None = None, limit: int = 100, offset: int = 0,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}")
|
||||
async def get_repository_commit(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, commit_hash: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
return get_commit_detail(repo.path, commit_hash)
|
||||
except (RuntimeError, ValueError) as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse)
|
||||
async def list_repository_files(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, branch: str = "main", path: str = "",
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> FileListResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
entries = list_tree(repo.path, branch=branch, path=path)
|
||||
return FileListResponse(path=path, branch=branch, entries=[
|
||||
{"name": e.name, "type": e.type, "path": e.path, "size": e.size, "mode": e.mode, "last_commit": e.last_commit}
|
||||
for e in entries
|
||||
])
|
||||
except RuntimeError as e:
|
||||
logger.error("Failed to list files for repo %s: %s", repo_id, str(e))
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse)
|
||||
async def get_repository_file_content(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, branch: str, path: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> FileContentResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
fc = get_file_content(repo.path, branch=branch, path=path)
|
||||
return FileContentResponse(path=fc.path, branch=fc.branch, content=fc.content, size=fc.size,
|
||||
encoding=fc.encoding, language=fc.language, is_binary=fc.is_binary, last_commit=fc.last_commit)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories/{repo_id}/branches", response_model=BranchesResponse)
|
||||
async def get_repository_branches(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> BranchesResponse:
|
||||
repo = await _get_repo(session, user_id, project_id, repo_id)
|
||||
is_valid = os.path.isdir(os.path.join(repo.path, ".git")) or os.path.isfile(os.path.join(repo.path, "HEAD"))
|
||||
if is_valid:
|
||||
try:
|
||||
branches, default_branch = list_branches(repo.path)
|
||||
return BranchesResponse(branches=[
|
||||
{"name": b.name, "is_default": b.is_default, "last_commit": b.last_commit} for b in branches
|
||||
], default_branch=default_branch)
|
||||
except RuntimeError as e:
|
||||
logger.error("Failed to list branches for repo %s: %s", repo_id, str(e))
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) from e
|
||||
if repo.remote_url:
|
||||
ssh_key = await session.get(SSHKey, repo.ssh_key_id) if repo.ssh_key_id else None
|
||||
try:
|
||||
remote_branches, default_branch = list_remote_branches(repo.remote_url, ssh_key)
|
||||
if remote_branches:
|
||||
return BranchesResponse(branches=[
|
||||
{"name": b, "is_default": b == default_branch, "last_commit": None} for b in remote_branches
|
||||
], default_branch=default_branch)
|
||||
except RuntimeError:
|
||||
pass
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk — re-clone or re-create the repository")
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse)
|
||||
async def update_repository_file(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, data: FileUpdateRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> FileUpdateResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
author_name, author_email = await _commit_author(session, user_id)
|
||||
try:
|
||||
commit_hash = commit_file(repo.path, data.branch, data.path, data.content, data.commit_message, author_name, author_email)
|
||||
return FileUpdateResponse(commit_hash=commit_hash, message=data.commit_message, branch=data.branch)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories/{repo_id}/status", response_model=StatusResponse)
|
||||
async def get_repository_status(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> StatusResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
s = get_status(repo.path)
|
||||
return StatusResponse(branch=s.branch, modified=s.modified, added=s.added, deleted=s.deleted,
|
||||
untracked=s.untracked, renamed=s.renamed, ahead=s.ahead, behind=s.behind)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/branches")
|
||||
async def create_repository_branch(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, data: BranchCreateRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
create_branch(repo.path, data.name, data.base_branch)
|
||||
return {"message": f"Branch '{data.name}' created", "branch": data.name}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{project_id}/repositories/{repo_id}/branches/{branch_name}")
|
||||
async def delete_repository_branch(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, branch_name: str, force: bool = False,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
delete_branch(repo.path, branch_name, force)
|
||||
return {"message": f"Branch '{branch_name}' deleted"}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/checkout")
|
||||
async def checkout_repository_branch(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, data: CheckoutRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
checkout_branch(repo.path, data.branch)
|
||||
return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/commit", response_model=CommitResponse)
|
||||
async def commit_repository_changes(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, data: CommitRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> CommitResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
author_name, author_email = await _commit_author(session, user_id)
|
||||
try:
|
||||
commit_hash = commit_changes(repo.path, data.message, author_name, author_email, data.files)
|
||||
return CommitResponse(commit_hash=commit_hash, message=data.message)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse)
|
||||
async def fetch_repository(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> FetchResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
fetch(repo.path)
|
||||
return FetchResponse(message="Fetched from remote")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/pull", response_model=PullResponse)
|
||||
async def pull_repository(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, branch: str | None = None,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> PullResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
pull(repo.path, branch)
|
||||
return PullResponse(message="Pulled from remote")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/push", response_model=PushResponse)
|
||||
async def push_repository(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, branch: str | None = None,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> PushResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
push(repo.path, branch)
|
||||
return PushResponse(message="Pushed to remote")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/merge", response_model=MergeResponse)
|
||||
async def merge_repository_branches(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, data: MergeRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> MergeResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
commit_hash = merge(repo.path, data.source_branch, data.target_branch, data.message)
|
||||
return MergeResponse(commit_hash=commit_hash, message=data.message or f"Merge {data.source_branch}")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
@@ -3,42 +3,29 @@ import shutil
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.auth.dependencies import (
|
||||
_get_owned_project,
|
||||
_get_user,
|
||||
get_current_user_id,
|
||||
get_db_session,
|
||||
)
|
||||
from src.models import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models import SSHKey
|
||||
from src.models import ToolInstance
|
||||
from src.schemas.project import (
|
||||
ProjectCreate,
|
||||
ProjectResponse,
|
||||
ProjectUpdate,
|
||||
SetDefaultSSHKeyRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
owner_id: uuid.UUID
|
||||
default_ssh_key_id: uuid.UUID | None
|
||||
|
||||
|
||||
class SetDefaultSSHKeyRequest(BaseModel):
|
||||
ssh_key_id: uuid.UUID
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ProjectResponse,
|
||||
@@ -76,26 +63,77 @@ async def create_project(
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[ProjectResponse],
|
||||
summary="List all projects",
|
||||
description="Retrieve all projects owned by the authenticated user.",
|
||||
description="Retrieve all projects owned by the authenticated user with repositories and workspaces.",
|
||||
)
|
||||
async def list_projects(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[Project]:
|
||||
) -> list[dict]:
|
||||
"""List all projects for the authenticated user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
List of projects owned by the user.
|
||||
Returns projects with nested repositories and workspaces for inline display.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
||||
return list(result.scalars().all())
|
||||
result = await session.execute(
|
||||
select(Project)
|
||||
.where(Project.owner_id == user.id)
|
||||
.order_by(Project.created_at.desc())
|
||||
)
|
||||
projects = result.scalars().all()
|
||||
|
||||
from src.models import Workspace
|
||||
|
||||
enriched = []
|
||||
for project in projects:
|
||||
repos_result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project.id)
|
||||
)
|
||||
repositories = []
|
||||
for repo in repos_result.scalars().all():
|
||||
ws_result = await session.execute(
|
||||
select(Workspace).where(Workspace.repo_id == repo.id)
|
||||
)
|
||||
workspaces = []
|
||||
for ws in ws_result.scalars().all():
|
||||
# Count instances
|
||||
inst_result = await session.execute(
|
||||
select(func.count()).where(ToolInstance.workspace_id == ws.id)
|
||||
)
|
||||
instance_count = inst_result.scalar() or 0
|
||||
workspaces.append(
|
||||
{
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"branch": ws.branch,
|
||||
"status": ws.status,
|
||||
"instance_count": instance_count,
|
||||
}
|
||||
)
|
||||
|
||||
repositories.append(
|
||||
{
|
||||
"id": str(repo.id),
|
||||
"name": repo.name,
|
||||
"remote_url": repo.remote_url,
|
||||
"workspaces": workspaces,
|
||||
}
|
||||
)
|
||||
|
||||
enriched.append(
|
||||
{
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"owner_id": str(project.owner_id),
|
||||
"repositories": repositories,
|
||||
"created_at": project.created_at.isoformat()
|
||||
if project.created_at
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
return enriched
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -184,7 +222,9 @@ async def delete_project(
|
||||
project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
# Delete repositories from disk and database
|
||||
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
||||
result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||
)
|
||||
repositories = result.scalars().all()
|
||||
for repo in repositories:
|
||||
if os.path.exists(repo.path):
|
||||
@@ -0,0 +1,26 @@
|
||||
# apps/api/src/api/system (index)
|
||||
dir: apps/api/src/api/system
|
||||
|
||||
## role
|
||||
Provides system-level API endpoints for monitoring, administration, and infrastructure interaction including dashboards, health checks, event streaming, notifications, and container terminal access.
|
||||
## parent
|
||||
index: apps/api/src/api/.pi-map.index.md
|
||||
map: apps/api/src/api/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- dashboard.py
|
||||
- events.py
|
||||
- health.py
|
||||
- instance_proxy.py
|
||||
- notifications.py
|
||||
- terminal.py
|
||||
## links
|
||||
index: apps/api/src/api/system/.pi-map.index.md
|
||||
map: apps/api/src/api/system/.pi-map.md
|
||||
## workflows
|
||||
- change system behavior
|
||||
read: __init__.py, dashboard.py, events.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,33 @@
|
||||
# apps/api/src/api/system
|
||||
dir: apps/api/src/api/system
|
||||
|
||||
index: apps/api/src/api/system/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides system-level API endpoints for monitoring, administration, and infrastructure interaction including dashboards, health checks, event streaming, notifications, and container terminal access.
|
||||
## files
|
||||
- __init__.py | Aggregates and re-exports system API router modules from submodules for centralized access. | dep: src.api.system.dashboard, src.api.system.events, src.api.system.health, src.api.system.instance_proxy, src.api.system.notifications, src.api.system.terminal
|
||||
- dashboard.py | Provides a FastAPI endpoint that returns a dashboard summary with aggregated counts of projects, repositories, SSH keys, and recent activity for the authenticated user. | exp: func:get_dashboard_summary(user_id, session) → dict, call:session.execute, call:select(func.count()).select_from(Project).where, call:func.count, call:projects_result.scalar, call:select(func.count()).select_from(GitRepository).where, call:repos_result.scalar, call:select(func.count()).select_from(SSHKey).where, call:ssh_keys_result.scalar, call:select(Project) .where(Project.owner_id == user_id) .order_by(Project.created_at.desc()) .limit, call:Project.created_at.desc, call:recent_projects.scalars().all | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.models.project, src.models (GitRepository, Project, SSHKey)
|
||||
- events.py | Implements an SSE streaming endpoint that delivers instance events to authenticated users with per-user connection limits and keep-alive pings. | exp: func:events_stream(request: Request, user_id) → StreamingResponse, call:_connection_counts.get, call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.pop, call:StreamingResponse, call:event_generator, raise:HTTPException, func:event_generator() → AsyncGenerator[str, None], call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.get, call:_connection_counts.pop, func:on_event(payload: InstanceEventPayload) → None, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait | dep: asyncio, contextlib, json, uuid, collections.abc, fastapi, fastapi.responses, src.auth.dependencies, src.services.instance.event_bus
|
||||
- health.py | Implements FastAPI health check endpoints for monitoring system status including database connectivity and disk space. | exp: func:health_check() → dict[str, Any], call:HealthChecks, call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealth, call:round, call:shutil.disk_usage, call:DiskHealth, call:HealthResponse( status=overall_status, timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), version="0.1.0", checks=checks, uptime_seconds=round(time.time() - _start_time, 2), ).model_dump, call:datetime.now(timezone.utc).isoformat().replace, call:time.time, func:health_check_db() → dict[str, Any], call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealthResponse( status="healthy", response_time_ms=round(db_time, 2), ).model_dump, call:round, call:DatabaseHealthResponse( status="unhealthy", response_time_ms=0.0, ).model_dump | dep: time, datetime, typing, fastapi, sqlalchemy, src.database, src.schemas.system, shutil
|
||||
- instance_proxy.py | Proxies HTTP requests from FastAPI endpoints to running containerized tool instances after verifying ownership and status. | exp: func:_proxy_request(request: Request, instance_id: uuid.UUID, path: str, user_id: uuid.UUID, session: AsyncSession) → Response, call:session.get, call:str, call:request.headers.items, call:key.lower, call:httpx.AsyncClient, call:request.body, call:client.request, call:logger.error, call:dict, call:response_headers.pop, call:Response, raise:HTTPException, func:proxy_to_instance(request: Request, instance_id: uuid.UUID, path, user_id, session) → Response, call:_proxy_request | dep: logging, uuid, httpx, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models
|
||||
- notifications.py | Defines FastAPI REST endpoints for managing user notifications (list, unread count, mark read, dismiss/clear) with support for muted categories from user config. | exp: class:NotificationItem, class:NotificationListResponse, class:UnreadCountResponse, class:MarkAllReadResponse, class:ClearAllResponse, func:_get_mute_categories(session: AsyncSession, user_id: uuid.UUID) → list[str], call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:config.config.get, call:isinstance, func:list_notifications(limit, offset, unread_only, user, session) → NotificationListResponse, call:_get_mute_categories, call:notification_service.list_notifications, call:NotificationListResponse, call:NotificationItem.model_validate, func:get_unread_count(user, session) → UnreadCountResponse, call:notification_service.get_unread_count, call:UnreadCountResponse, func:mark_notification_read(notification_id: uuid.UUID, user, session) → NotificationItem, call:notification_service.mark_read, call:NotificationItem.model_validate, raise:HTTPException, func:mark_all_read(user, session) → MarkAllReadResponse, call:notification_service.mark_all_read, call:MarkAllReadResponse, func:clear_all_notifications(user, session) → ClearAllResponse, call:notification_service.dismiss_all, call:ClearAllResponse, func:dismiss_notification(notification_id: uuid.UUID, user, session) → None, call:notification_service.dismiss, raise:HTTPException | dep: uuid, datetime, fastapi, pydantic, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models.user, src.models, src.services.shared.notification_service, sqlalchemy
|
||||
- terminal.py | Provides WebSocket and HTTP endpoints for managing interactive terminal sessions attached to running Docker container tool instances, including session creation, attachment, input/output streaming, resize/reset control messages, and session listing. | exp: class:SessionRef, method:__init__(self, session, slot_session_id), func:terminal_websocket_default(websocket: WebSocket, instance_id: str, db_session) → None, call:_handle_terminal_websocket, func:terminal_websocket_specific(websocket: WebSocket, instance_id: str, session_id: str, db_session) → None, call:_handle_terminal_websocket, func:_handle_terminal_websocket(websocket: WebSocket, instance_id: str, target_session_id: str | None, db_session: AsyncSession) → None, call:logger.debug, call:websocket.accept, call:uuid.UUID, call:logger.error, call:websocket.close, call:_get_user_from_websocket, call:logger.warning, call:db_session.get, call:get_container_status, call:terminal_manager.get_or_create_session, call:terminal_manager.get_session, call:logger.info, call:terminal_manager.create_session, call:terminal_manager._find_key_by_internal_id, call:terminal_manager.attach_websocket, call:websocket.send_json, call:SessionRef, call:asyncio.create_task, call:_write_loop, call:_heartbeat_loop, call:asyncio.wait, call:len, call:task.cancel, call:str, call:suppress, call:terminal_manager.detach_websocket, func:_write_loop(session_ref: SessionRef, websocket, instance_id: str) → None, call:session.is_alive, call:asyncio.sleep, call:websocket.receive, call:session.write_input, call:text.startswith, call:json.loads, call:ctrl.get, call:logger.debug, call:session.resize, call:session.acknowledge_data, call:websocket.send_json, call:terminal_manager.reset_session, call:terminal_manager.attach_websocket, call:text.encode, func:_heartbeat_loop(websocket: WebSocket) → None, call:asyncio.sleep, call:websocket.send_json, func:_get_terminal_instance(instance_id: uuid.UUID, user_id: uuid.UUID, db_session: AsyncSession) → ToolInstance, call:db_session.get, raise:HTTPException, func:list_terminal_sessions(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.execute, call:select(TerminalSessionModel) .where(TerminalSessionModel.instance_id == instance_id) .where(TerminalSessionModel.status != "closed") .order_by, call:TerminalSessionModel.created_at.asc, call:result.scalars().all, call:terminal_manager.get_session, call:str, call:sessions.append, call:live_session.has_websockets, call:row.created_at.isoformat, call:row.last_activity_at.isoformat, func:create_terminal_session(instance_id: uuid.UUID, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:data.get, call:terminal_manager.create_session, raise:HTTPException, func:close_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:terminal_manager.close_session, raise:HTTPException, func:reset_specific_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:db_session.get, call:terminal_manager.reset_session, raise:HTTPException, func:rename_terminal_session(instance_id: uuid.UUID, session_id: str, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:data.get, call:isinstance, call:terminal_manager.get_session, call:str, call:db_session.get, call:uuid.UUID, call:db_session.commit, raise:HTTPException, func:reset_terminal_session(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:terminal_manager.reset_session, call:logger.info, call:str, call:logger.error, raise:HTTPException, func:_get_user_from_websocket(websocket: WebSocket, db_session: AsyncSession) → uuid.UUID | None, call:websocket.cookies.get, call:Settings, call:decode_session_cookie, call:uuid.UUID, call:str | dep: asyncio, json, logging, uuid, contextlib, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, starlette.websockets, src.auth.dependencies, src.models, src.services.terminal.terminal_manager, src.services.docker, src.auth.session, src.config, starlette
|
||||
## arch
|
||||
FastAPI router modules organized by domain concern with async/await patterns, SSE/WebSocket for real-time streaming, proxy pattern for container instance forwarding, and per-user authentication/authorization with connection limiting.
|
||||
## tags
|
||||
session, terminal, call:terminal, call:, src, get, response, websocket
|
||||
## symbols
|
||||
- NotificationItem
|
||||
- NotificationListResponse
|
||||
- UnreadCountResponse
|
||||
- MarkAllReadResponse
|
||||
- ClearAllResponse
|
||||
- SessionRef
|
||||
- get_dashboard_summary
|
||||
- events_stream
|
||||
## workflows
|
||||
- change system behavior
|
||||
read: __init__.py, dashboard.py, events.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,17 @@
|
||||
"""System API routers module."""
|
||||
|
||||
from src.api.system.dashboard import router as dashboard_router
|
||||
from src.api.system.events import router as events_router
|
||||
from src.api.system.health import router as health_router
|
||||
from src.api.system.instance_proxy import router as instance_proxy_router
|
||||
from src.api.system.notifications import router as notifications_router
|
||||
from src.api.system.terminal import router as terminal_router
|
||||
|
||||
__all__ = [
|
||||
"dashboard_router",
|
||||
"events_router",
|
||||
"health_router",
|
||||
"instance_proxy_router",
|
||||
"notifications_router",
|
||||
"terminal_router",
|
||||
]
|
||||
@@ -5,9 +5,9 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models import SSHKey
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""SSE streaming endpoint for instance events."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from src.auth.dependencies import get_current_user_id
|
||||
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
|
||||
# In-memory connection counter per user (single-process assumption)
|
||||
_connection_counts: dict[uuid.UUID, int] = {}
|
||||
MAX_CONNECTIONS_PER_USER = 20
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def events_stream(
|
||||
request: Request,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
) -> StreamingResponse:
|
||||
"""Stream instance events via Server-Sent Events.
|
||||
|
||||
Enforces a maximum of 5 concurrent connections per user.
|
||||
"""
|
||||
current = _connection_counts.get(user_id, 0)
|
||||
if current >= MAX_CONNECTIONS_PER_USER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many SSE connections",
|
||||
)
|
||||
|
||||
_connection_counts[user_id] = current + 1
|
||||
|
||||
async def event_generator() -> AsyncGenerator[str, None]:
|
||||
event_bus = InstanceEventBus()
|
||||
queue: asyncio.Queue[InstanceEventPayload] = asyncio.Queue(maxsize=100)
|
||||
|
||||
async def on_event(payload: InstanceEventPayload) -> None:
|
||||
try:
|
||||
queue.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
# Drop oldest event to make room
|
||||
with contextlib.suppress(asyncio.QueueEmpty):
|
||||
queue.get_nowait()
|
||||
with contextlib.suppress(asyncio.QueueFull):
|
||||
queue.put_nowait(payload)
|
||||
|
||||
unsubscribe = event_bus.subscribe("*", on_event)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
payload = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
yield f"event: {payload['event']}\ndata: {json.dumps(payload)}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
yield ":ping\n\n"
|
||||
except asyncio.CancelledError:
|
||||
# Client disconnected
|
||||
raise
|
||||
finally:
|
||||
unsubscribe()
|
||||
_connection_counts[user_id] = max(0, _connection_counts.get(user_id, 1) - 1)
|
||||
if _connection_counts[user_id] == 0:
|
||||
_connection_counts.pop(user_id, None)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
@@ -5,10 +5,16 @@ from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
|
||||
from src.database import SessionLocal
|
||||
from src.schemas.system import (
|
||||
DatabaseHealth,
|
||||
DatabaseHealthResponse,
|
||||
DiskHealth,
|
||||
HealthChecks,
|
||||
HealthResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -16,45 +22,6 @@ router = APIRouter()
|
||||
_start_time = time.time()
|
||||
|
||||
|
||||
class DatabaseHealth(BaseModel):
|
||||
"""Database health check result."""
|
||||
|
||||
status: str = Field(description="Database health status", examples=["healthy"])
|
||||
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
|
||||
|
||||
|
||||
class DiskHealth(BaseModel):
|
||||
"""Disk space health check result."""
|
||||
|
||||
status: str = Field(description="Disk health status", examples=["healthy"])
|
||||
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
|
||||
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
|
||||
|
||||
|
||||
class HealthChecks(BaseModel):
|
||||
"""Individual health checks."""
|
||||
|
||||
database: DatabaseHealth | None = None
|
||||
disk: DiskHealth | None = None
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Overall health check response."""
|
||||
|
||||
status: str = Field(description="Overall health status", examples=["healthy"])
|
||||
timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"])
|
||||
version: str = Field(description="API version", examples=["0.1.0"])
|
||||
checks: HealthChecks = Field(description="Individual health checks")
|
||||
uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0])
|
||||
|
||||
|
||||
class DatabaseHealthResponse(BaseModel):
|
||||
"""Database-specific health check response."""
|
||||
|
||||
status: str = Field(description="Database health status", examples=["healthy"])
|
||||
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/health",
|
||||
response_model=HealthResponse,
|
||||
@@ -8,8 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models import ToolInstance
|
||||
from src.models import ToolType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Notification API endpoints."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user, get_db_session
|
||||
from src.models.user import User
|
||||
from src.models import UserConfig
|
||||
from src.services.shared.notification_service import notification_service
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
class NotificationItem(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
category: str
|
||||
severity: str
|
||||
title: str
|
||||
message: str | None
|
||||
source_type: str | None
|
||||
source_id: uuid.UUID | None
|
||||
notification_metadata: dict = Field(serialization_alias="metadata")
|
||||
read_at: datetime | None
|
||||
dismissed_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
items: list[NotificationItem]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class MarkAllReadResponse(BaseModel):
|
||||
marked_count: int
|
||||
|
||||
|
||||
class ClearAllResponse(BaseModel):
|
||||
cleared_count: int
|
||||
|
||||
|
||||
async def _get_mute_categories(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
) -> list[str]:
|
||||
"""Read notification mute categories from user config."""
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
return []
|
||||
mute_categories = config.config.get("notification_mute_categories", [])
|
||||
if isinstance(mute_categories, list):
|
||||
return mute_categories
|
||||
return []
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationListResponse)
|
||||
async def list_notifications(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
unread_only: bool = Query(False),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> NotificationListResponse:
|
||||
"""List notifications for the authenticated user."""
|
||||
mute_categories = await _get_mute_categories(session, user.id)
|
||||
items, total = await notification_service.list_notifications(
|
||||
session,
|
||||
user.id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
unread_only=unread_only,
|
||||
mute_categories=mute_categories,
|
||||
)
|
||||
return NotificationListResponse(
|
||||
items=[NotificationItem.model_validate(item) for item in items],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread", response_model=UnreadCountResponse)
|
||||
async def get_unread_count(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> UnreadCountResponse:
|
||||
"""Get unread notification count for the authenticated user."""
|
||||
count = await notification_service.get_unread_count(session, user.id)
|
||||
return UnreadCountResponse(count=count)
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read", response_model=NotificationItem)
|
||||
async def mark_notification_read(
|
||||
notification_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> NotificationItem:
|
||||
"""Mark a single notification as read."""
|
||||
try:
|
||||
notification = await notification_service.mark_read(
|
||||
session, notification_id, user.id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found",
|
||||
) from exc
|
||||
return NotificationItem.model_validate(notification)
|
||||
|
||||
|
||||
@router.post("/mark-all-read", response_model=MarkAllReadResponse)
|
||||
async def mark_all_read(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> MarkAllReadResponse:
|
||||
"""Mark all unread notifications as read."""
|
||||
marked = await notification_service.mark_all_read(session, user.id)
|
||||
return MarkAllReadResponse(marked_count=marked)
|
||||
|
||||
|
||||
@router.delete("", status_code=status.HTTP_200_OK)
|
||||
async def clear_all_notifications(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ClearAllResponse:
|
||||
"""Dismiss all notifications for the authenticated user."""
|
||||
cleared = await notification_service.dismiss_all(session, user.id)
|
||||
return ClearAllResponse(cleared_count=cleared)
|
||||
|
||||
|
||||
@router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def dismiss_notification(
|
||||
notification_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Soft-delete (dismiss) a single notification."""
|
||||
try:
|
||||
await notification_service.dismiss(session, notification_id, user.id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found",
|
||||
) from exc
|
||||
@@ -0,0 +1,749 @@
|
||||
"""WebSocket terminal endpoint for tool instances."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models import TerminalSessionModel
|
||||
from src.models import ToolInstance
|
||||
from src.models import ToolType
|
||||
from src.services.terminal.terminal_manager import MaxSessionsExceededError, terminal_manager
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionRef:
|
||||
"""Mutable reference to a terminal session, allowing updates during reset."""
|
||||
|
||||
def __init__(self, session, slot_session_id: str | None = None):
|
||||
self.session = session
|
||||
self.slot_session_id = slot_session_id or session.session_id
|
||||
|
||||
|
||||
@router.websocket(
|
||||
"/ws/tool-instances/{instance_id}/terminal",
|
||||
)
|
||||
async def terminal_websocket_default(
|
||||
websocket: WebSocket,
|
||||
instance_id: str,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for terminal access (default session alias).
|
||||
|
||||
Backward-compatible route that maps to the default session.
|
||||
"""
|
||||
await _handle_terminal_websocket(websocket, instance_id, None, db_session)
|
||||
|
||||
|
||||
@router.websocket(
|
||||
"/ws/tool-instances/{instance_id}/terminal/{session_id}",
|
||||
)
|
||||
async def terminal_websocket_specific(
|
||||
websocket: WebSocket,
|
||||
instance_id: str,
|
||||
session_id: str,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for a specific terminal session."""
|
||||
await _handle_terminal_websocket(websocket, instance_id, session_id, db_session)
|
||||
|
||||
|
||||
async def _handle_terminal_websocket(
|
||||
websocket: WebSocket,
|
||||
instance_id: str,
|
||||
target_session_id: str | None,
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Shared WebSocket handler for terminal sessions.
|
||||
|
||||
Args:
|
||||
websocket: The WebSocket connection.
|
||||
instance_id: UUID string of the tool instance.
|
||||
target_session_id: Specific session ID (slot key). None means default session.
|
||||
db_session: Database session.
|
||||
"""
|
||||
logger.debug(
|
||||
"Terminal WebSocket connection attempt for instance %s (session=%s)",
|
||||
instance_id,
|
||||
target_session_id or "default",
|
||||
)
|
||||
await websocket.accept()
|
||||
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||
|
||||
try:
|
||||
# Parse instance_id
|
||||
instance_uuid = uuid.UUID(instance_id)
|
||||
except ValueError:
|
||||
logger.error("Invalid instance ID: %s", instance_id)
|
||||
await websocket.close(code=4001, reason="Invalid instance ID")
|
||||
return
|
||||
|
||||
# Authenticate user from session cookie
|
||||
user_id = await _get_user_from_websocket(websocket, db_session)
|
||||
if user_id is None:
|
||||
logger.warning(
|
||||
"Unauthorized terminal access attempt for instance %s", instance_id
|
||||
)
|
||||
await websocket.close(code=4003, reason="Unauthorized")
|
||||
return
|
||||
|
||||
# Get instance and verify ownership
|
||||
instance = await db_session.get(ToolInstance, instance_uuid)
|
||||
if instance is None:
|
||||
logger.warning("Instance %s not found", instance_id)
|
||||
await websocket.close(code=4004, reason="Instance not found")
|
||||
return
|
||||
|
||||
if instance.owner_id != user_id:
|
||||
logger.warning(
|
||||
"Forbidden terminal access for instance %s by user %s",
|
||||
instance_id,
|
||||
user_id,
|
||||
)
|
||||
await websocket.close(code=4003, reason="Forbidden")
|
||||
return
|
||||
|
||||
if instance.status != "running" or not instance.container_id:
|
||||
logger.warning(
|
||||
"Instance %s not running (status=%s, container_id=%s)",
|
||||
instance_id,
|
||||
instance.status,
|
||||
instance.container_id,
|
||||
)
|
||||
await websocket.close(code=4004, reason="Instance not running")
|
||||
return
|
||||
|
||||
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
||||
|
||||
# Verify the container actually exists (may have been removed/recreated)
|
||||
from src.services.docker import get_container_status
|
||||
|
||||
container_status = get_container_status(instance.container_id)
|
||||
if container_status["status"] == "not_found":
|
||||
logger.error(
|
||||
"Container %s for instance %s not found (may have been removed)",
|
||||
instance.container_id,
|
||||
instance_id,
|
||||
)
|
||||
await websocket.close(
|
||||
code=4004, reason="Container not found — restart the tool instance"
|
||||
)
|
||||
return
|
||||
|
||||
# Fetch tool type to get startup_command
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
if startup_command:
|
||||
logger.debug(
|
||||
"Using startup command for instance %s: %s",
|
||||
instance_id,
|
||||
startup_command,
|
||||
)
|
||||
|
||||
session = None
|
||||
|
||||
# Get or create terminal session
|
||||
try:
|
||||
if target_session_id is None:
|
||||
# Default session alias
|
||||
session = await terminal_manager.get_or_create_session(
|
||||
instance_uuid,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
)
|
||||
slot_session_id = "default"
|
||||
else:
|
||||
# Specific session
|
||||
session = terminal_manager.get_session(
|
||||
instance_id,
|
||||
target_session_id,
|
||||
)
|
||||
if session is None:
|
||||
# Session not in memory — may have been lost on server restart.
|
||||
# Try to restore from the DB row.
|
||||
db_row = await db_session.get(
|
||||
TerminalSessionModel, uuid.UUID(target_session_id)
|
||||
)
|
||||
if (
|
||||
db_row is not None
|
||||
and db_row.instance_id == instance_uuid
|
||||
and db_row.status != "closed"
|
||||
):
|
||||
logger.info(
|
||||
"Restoring terminal session %s for instance %s from DB",
|
||||
target_session_id,
|
||||
instance_id,
|
||||
)
|
||||
session = await terminal_manager.create_session(
|
||||
instance_uuid,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
name=db_row.name,
|
||||
session_id=target_session_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Session %s not found for instance %s",
|
||||
target_session_id,
|
||||
instance_id,
|
||||
)
|
||||
await websocket.close(code=4004, reason="Session not found")
|
||||
return
|
||||
# Determine slot key for reset scoping
|
||||
key = terminal_manager._find_key_by_internal_id(
|
||||
instance_id, session.session_id
|
||||
)
|
||||
slot_session_id = key[1] if key else target_session_id
|
||||
|
||||
logger.debug(
|
||||
"Terminal session ready for instance %s (session_id=%s, slot=%s)",
|
||||
instance_id,
|
||||
session.session_id,
|
||||
slot_session_id,
|
||||
)
|
||||
|
||||
# Attach WebSocket to session
|
||||
await terminal_manager.attach_websocket(session, websocket)
|
||||
logger.debug("WebSocket attached to session for instance %s", instance_id)
|
||||
|
||||
# Send connected status
|
||||
await websocket.send_json({"type": "status", "status": "connected"})
|
||||
logger.debug("Sent connected status for instance %s", instance_id)
|
||||
|
||||
# Use mutable session reference so loops can survive reset
|
||||
session_ref = SessionRef(session, slot_session_id)
|
||||
|
||||
# Start write loop and heartbeat (read is now event-driven in TerminalSession)
|
||||
write_task = asyncio.create_task(
|
||||
_write_loop(session_ref, websocket, instance_id)
|
||||
)
|
||||
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||
logger.debug("Started terminal loops for instance %s", instance_id)
|
||||
|
||||
# Wait for either task to complete (indicating disconnect or error)
|
||||
done, pending = await asyncio.wait(
|
||||
[write_task, heartbeat_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Terminal loop completed for instance %s, done=%s",
|
||||
instance_id,
|
||||
len(done),
|
||||
)
|
||||
|
||||
# Cancel remaining tasks
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("WebSocket disconnected for instance %s", instance_id)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Terminal session error for instance %s: %s",
|
||||
instance_id,
|
||||
str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
with suppress(Exception):
|
||||
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||
finally:
|
||||
# Detach WebSocket, don't kill session
|
||||
with suppress(Exception):
|
||||
if session is not None:
|
||||
await terminal_manager.detach_websocket(session, websocket)
|
||||
logger.debug(
|
||||
"WebSocket detached from session for instance %s", instance_id
|
||||
)
|
||||
|
||||
|
||||
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||
"""Read input from WebSocket and send to container."""
|
||||
try:
|
||||
while True:
|
||||
session = session_ref.session
|
||||
if not session.is_alive() or session._closed:
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
message = await websocket.receive()
|
||||
if message["type"] == "websocket.receive":
|
||||
if "bytes" in message:
|
||||
await session.write_input(message["bytes"])
|
||||
elif "text" in message:
|
||||
text = message["text"]
|
||||
if text.startswith("{"):
|
||||
# Control message (JSON)
|
||||
try:
|
||||
ctrl = json.loads(text)
|
||||
msg_type = ctrl.get("type")
|
||||
|
||||
if msg_type == "resize":
|
||||
cols = ctrl.get("cols", 80)
|
||||
rows = ctrl.get("rows", 24)
|
||||
logger.debug(
|
||||
"Received resize message for instance %s: %sx%s",
|
||||
instance_id,
|
||||
cols,
|
||||
rows,
|
||||
)
|
||||
await session.resize(cols, rows)
|
||||
elif msg_type == "ack":
|
||||
char_count = ctrl.get("chars", 0)
|
||||
if char_count > 0:
|
||||
session.acknowledge_data(char_count)
|
||||
elif msg_type == "reset":
|
||||
# Reset terminal session (scoped to current slot)
|
||||
logger.debug(
|
||||
"Resetting terminal session for instance %s (slot=%s)",
|
||||
session.instance_id,
|
||||
session_ref.slot_session_id,
|
||||
)
|
||||
await websocket.send_json(
|
||||
{"type": "status", "status": "resetting"}
|
||||
)
|
||||
|
||||
# Reset the session scoped to its slot
|
||||
new_session = await terminal_manager.reset_session(
|
||||
session.instance_id,
|
||||
session.container_id,
|
||||
startup_command=session.startup_command,
|
||||
session_id=session_ref.slot_session_id,
|
||||
name=session.name,
|
||||
)
|
||||
|
||||
# Update the mutable session reference
|
||||
session_ref.session = new_session
|
||||
|
||||
# Attach to new session
|
||||
await terminal_manager.attach_websocket(
|
||||
new_session, websocket
|
||||
)
|
||||
await websocket.send_json(
|
||||
{"type": "status", "status": "connected"}
|
||||
)
|
||||
|
||||
# Continue the loop with the new session
|
||||
continue
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Not a valid JSON control message, treat as regular input
|
||||
await session.write_input(text.encode("utf-8"))
|
||||
else:
|
||||
await session.write_input(text.encode("utf-8"))
|
||||
elif message["type"] == "websocket.disconnect":
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
||||
"""Send periodic ping messages to detect disconnections."""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(30) # Ping every 30 seconds
|
||||
try:
|
||||
await websocket.send_json({"type": "ping"})
|
||||
except Exception:
|
||||
# WebSocket is closed or broken
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _get_terminal_instance(
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
db_session: AsyncSession,
|
||||
) -> ToolInstance:
|
||||
"""Fetch instance and validate auth, ownership, and running status.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
The validated ToolInstance.
|
||||
|
||||
Raises:
|
||||
HTTPException: If instance not found, not owned, or not running.
|
||||
"""
|
||||
instance = await db_session.get(ToolInstance, instance_id)
|
||||
if instance is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Instance not found"
|
||||
)
|
||||
|
||||
if instance.owner_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to access this instance",
|
||||
)
|
||||
|
||||
if instance.status != "running" or not instance.container_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Instance is not running"
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
@router.get(
|
||||
"/instances/{instance_id}/terminal/sessions",
|
||||
summary="List terminal sessions",
|
||||
description="List terminal sessions for a tool instance with live WebSocket state.",
|
||||
)
|
||||
async def list_terminal_sessions(
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""List terminal sessions for an instance.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with sessions list.
|
||||
"""
|
||||
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
|
||||
# Query active DB rows for this instance
|
||||
result = await db_session.execute(
|
||||
select(TerminalSessionModel)
|
||||
.where(TerminalSessionModel.instance_id == instance_id)
|
||||
.where(TerminalSessionModel.status != "closed")
|
||||
.order_by(TerminalSessionModel.created_at.asc())
|
||||
)
|
||||
db_rows = result.scalars().all()
|
||||
|
||||
# Build response with live has_websockets flag.
|
||||
# Include DB rows even without in-memory counterparts (e.g. after
|
||||
# server restart) so the frontend can display tabs and reconnect.
|
||||
sessions = []
|
||||
for row in db_rows:
|
||||
live_session = terminal_manager.get_session(str(instance_id), str(row.id))
|
||||
sessions.append(
|
||||
{
|
||||
"id": str(row.id),
|
||||
"name": row.name,
|
||||
"status": row.status,
|
||||
"has_websockets": live_session.has_websockets()
|
||||
if live_session
|
||||
else False,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"last_activity_at": row.last_activity_at.isoformat()
|
||||
if row.last_activity_at
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
return {"sessions": sessions}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/terminal/sessions",
|
||||
summary="Create terminal session",
|
||||
description="Create a new terminal session for a running tool instance.",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Create a new terminal session.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
data: Request body with optional name.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with new session details.
|
||||
|
||||
Raises:
|
||||
HTTPException: 409 if max sessions reached.
|
||||
"""
|
||||
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
assert instance.container_id is not None
|
||||
|
||||
# Fetch tool type to get startup_command
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
|
||||
name = data.get("name")
|
||||
|
||||
try:
|
||||
session = await terminal_manager.create_session(
|
||||
instance_id,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
name=name,
|
||||
)
|
||||
except MaxSessionsExceededError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Maximum of 5 terminal sessions reached for this instance",
|
||||
) from None
|
||||
|
||||
return {
|
||||
"id": session.session_id,
|
||||
"name": session.name,
|
||||
"status": session.status,
|
||||
"created_at": session.last_activity,
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/instances/{instance_id}/terminal/sessions/{session_id}",
|
||||
summary="Close terminal session",
|
||||
description="Close a specific terminal session.",
|
||||
)
|
||||
async def close_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
session_id: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Close a terminal session.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
session_id: ID of the session to close.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with closure status.
|
||||
"""
|
||||
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
|
||||
# Find the session by internal ID to determine its slot key
|
||||
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
|
||||
if (
|
||||
key is None
|
||||
and terminal_manager.get_session(str(instance_id), session_id) is not None
|
||||
):
|
||||
key = (str(instance_id), session_id)
|
||||
|
||||
if key is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||
)
|
||||
|
||||
await terminal_manager.close_session(key[0], key[1])
|
||||
|
||||
return {"status": "closed", "session_id": session_id}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/terminal/sessions/{session_id}/reset",
|
||||
summary="Reset terminal session",
|
||||
description="Reset a specific terminal session, killing the current shell and starting fresh.",
|
||||
)
|
||||
async def reset_specific_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
session_id: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Reset a specific terminal session.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
session_id: ID of the session to reset.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with reset session details.
|
||||
"""
|
||||
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
assert instance.container_id is not None
|
||||
|
||||
# Determine slot key for reset
|
||||
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
|
||||
if (
|
||||
key is None
|
||||
and terminal_manager.get_session(str(instance_id), session_id) is not None
|
||||
):
|
||||
key = (str(instance_id), session_id)
|
||||
|
||||
if key is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||
)
|
||||
|
||||
# Fetch tool type to get startup_command
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
|
||||
# Preserve name if possible
|
||||
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||
name = live_session.name if live_session else None
|
||||
|
||||
new_session = await terminal_manager.reset_session(
|
||||
instance_id,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
session_id=key[1],
|
||||
name=name,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": new_session.session_id,
|
||||
"name": new_session.name,
|
||||
"status": new_session.status,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/terminal/sessions/{session_id}/rename",
|
||||
summary="Rename terminal session",
|
||||
description="Rename a specific terminal session.",
|
||||
)
|
||||
async def rename_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
session_id: str,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Rename a terminal session.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
session_id: ID of the session to rename.
|
||||
data: Request body with new name.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with updated session details.
|
||||
"""
|
||||
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
|
||||
new_name = data.get("name")
|
||||
if not new_name or not isinstance(new_name, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required"
|
||||
)
|
||||
|
||||
# Update in-memory session name if live
|
||||
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||
if live_session:
|
||||
live_session.name = new_name
|
||||
|
||||
# Update DB row
|
||||
db_row = await db_session.get(TerminalSessionModel, uuid.UUID(session_id))
|
||||
if db_row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||
)
|
||||
|
||||
db_row.name = new_name
|
||||
await db_session.commit()
|
||||
|
||||
return {"id": str(db_row.id), "name": new_name}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/terminal/reset",
|
||||
summary="Reset terminal session (legacy alias)",
|
||||
description="Reset the default terminal session for a tool instance. Preserved for backward compatibility.",
|
||||
)
|
||||
async def reset_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Reset the default terminal session for an instance (legacy alias).
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with status message.
|
||||
"""
|
||||
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
assert instance.container_id is not None
|
||||
|
||||
# Fetch tool type to get startup_command
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
|
||||
try:
|
||||
# Reset the default session
|
||||
new_session = await terminal_manager.reset_session(
|
||||
instance_id,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Terminal session reset for instance %s (new session_id=%s)",
|
||||
instance_id,
|
||||
new_session.session_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Terminal session reset successfully",
|
||||
"instance_id": str(instance_id),
|
||||
"session_id": new_session.session_id,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to reset terminal session for instance %s: %s",
|
||||
instance_id,
|
||||
str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to reset terminal session: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
async def _get_user_from_websocket(
|
||||
websocket: WebSocket,
|
||||
db_session: AsyncSession,
|
||||
) -> uuid.UUID | None:
|
||||
"""Extract and validate user ID from session cookie in WebSocket.
|
||||
|
||||
Args:
|
||||
websocket: The WebSocket connection.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
The user's UUID if authenticated, None otherwise.
|
||||
"""
|
||||
from src.auth.session import decode_session_cookie
|
||||
from src.config import Settings
|
||||
|
||||
session_cookie = websocket.cookies.get("session")
|
||||
if not session_cookie:
|
||||
return None
|
||||
|
||||
settings = Settings()
|
||||
try:
|
||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||
return uuid.UUID(str(payload["user_id"]))
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
@@ -1,324 +0,0 @@
|
||||
"""WebSocket terminal endpoint for tool instances."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_db_session
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.services.terminal_manager import terminal_manager
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionRef:
|
||||
"""Mutable reference to a terminal session, allowing updates during reset."""
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
|
||||
@router.websocket(
|
||||
"/ws/tool-instances/{instance_id}/terminal",
|
||||
)
|
||||
async def terminal_websocket(
|
||||
websocket: WebSocket,
|
||||
instance_id: str,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for terminal access to a tool instance.
|
||||
|
||||
Provides an interactive terminal session inside a running tool instance container.
|
||||
Sessions persist across WebSocket disconnections.
|
||||
|
||||
Args:
|
||||
websocket: The WebSocket connection.
|
||||
instance_id: UUID string of the tool instance.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
None. Communicates via WebSocket messages.
|
||||
"""
|
||||
logger.debug("Terminal WebSocket connection attempt for instance %s", instance_id)
|
||||
await websocket.accept()
|
||||
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||
|
||||
try:
|
||||
# Parse instance_id
|
||||
instance_uuid = uuid.UUID(instance_id)
|
||||
except ValueError:
|
||||
logger.error("Invalid instance ID: %s", instance_id)
|
||||
await websocket.close(code=4001, reason="Invalid instance ID")
|
||||
return
|
||||
|
||||
# Authenticate user from session cookie
|
||||
user_id = await _get_user_from_websocket(websocket, db_session)
|
||||
if user_id is None:
|
||||
logger.warning("Unauthorized terminal access attempt for instance %s", instance_id)
|
||||
await websocket.close(code=4003, reason="Unauthorized")
|
||||
return
|
||||
|
||||
# Get instance and verify ownership
|
||||
instance = await db_session.get(ToolInstance, instance_uuid)
|
||||
if instance is None:
|
||||
logger.warning("Instance %s not found", instance_id)
|
||||
await websocket.close(code=4004, reason="Instance not found")
|
||||
return
|
||||
|
||||
if instance.owner_id != user_id:
|
||||
logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id)
|
||||
await websocket.close(code=4003, reason="Forbidden")
|
||||
return
|
||||
|
||||
if instance.status != "running" or not instance.container_id:
|
||||
logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id)
|
||||
await websocket.close(code=4004, reason="Instance not running")
|
||||
return
|
||||
|
||||
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
||||
|
||||
# Fetch tool type to get startup_command
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
if startup_command:
|
||||
logger.debug("Using startup command for instance %s: %s", instance_id, startup_command)
|
||||
|
||||
# Get or create terminal session
|
||||
try:
|
||||
session = await terminal_manager.get_or_create_session(
|
||||
instance_uuid,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
)
|
||||
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
||||
|
||||
# Attach WebSocket to session
|
||||
await terminal_manager.attach_websocket(session, websocket)
|
||||
logger.debug("WebSocket attached to session for instance %s", instance_id)
|
||||
|
||||
# Send connected status
|
||||
await websocket.send_json({"type": "status", "status": "connected"})
|
||||
logger.debug("Sent connected status for instance %s", instance_id)
|
||||
|
||||
# Use mutable session reference so loops can survive reset
|
||||
session_ref = SessionRef(session)
|
||||
|
||||
# Start I/O loops and heartbeat
|
||||
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
||||
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
|
||||
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||
logger.debug("Started terminal loops for instance %s", instance_id)
|
||||
|
||||
# Wait for either task to complete (indicating disconnect or error)
|
||||
done, pending = await asyncio.wait(
|
||||
[read_task, write_task, heartbeat_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
logger.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
|
||||
|
||||
# Cancel remaining tasks
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
|
||||
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||
finally:
|
||||
# Detach WebSocket, don't kill session
|
||||
try:
|
||||
if 'session' in locals():
|
||||
await terminal_manager.detach_websocket(session, websocket)
|
||||
logger.debug("WebSocket detached from session for instance %s", instance_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _read_loop(session_ref: SessionRef, websocket) -> None:
|
||||
"""Read output from the container and send to WebSocket."""
|
||||
try:
|
||||
while True:
|
||||
session = session_ref.session
|
||||
if not session.is_alive() or session._closed:
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
data = await session.read_output()
|
||||
if data:
|
||||
try:
|
||||
await websocket.send_bytes(data)
|
||||
except Exception:
|
||||
break
|
||||
else:
|
||||
await asyncio.sleep(0.01)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||
"""Read input from WebSocket and send to container."""
|
||||
try:
|
||||
while True:
|
||||
session = session_ref.session
|
||||
if not session.is_alive() or session._closed:
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
message = await websocket.receive()
|
||||
if message["type"] == "websocket.receive":
|
||||
if "bytes" in message:
|
||||
await session.write_input(message["bytes"])
|
||||
elif "text" in message:
|
||||
text = message["text"]
|
||||
if text.startswith("{"):
|
||||
# Control message (JSON)
|
||||
import json
|
||||
try:
|
||||
ctrl = json.loads(text)
|
||||
msg_type = ctrl.get("type")
|
||||
|
||||
if msg_type == "resize":
|
||||
cols = ctrl.get("cols", 80)
|
||||
rows = ctrl.get("rows", 24)
|
||||
logger.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}")
|
||||
await session.resize(cols, rows)
|
||||
elif msg_type == "reset":
|
||||
# Reset terminal session
|
||||
logger.debug("Resetting terminal session for instance %s", session.instance_id)
|
||||
await websocket.send_json({"type": "status", "status": "resetting"})
|
||||
|
||||
# Reset the session
|
||||
new_session = await terminal_manager.reset_session(
|
||||
session.instance_id,
|
||||
session.container_id,
|
||||
startup_command=session.startup_command,
|
||||
)
|
||||
|
||||
# Update the mutable session reference so read_loop uses the new session
|
||||
session_ref.session = new_session
|
||||
|
||||
# Attach to new session
|
||||
await terminal_manager.attach_websocket(new_session, websocket)
|
||||
await websocket.send_json({"type": "status", "status": "connected"})
|
||||
|
||||
# Continue the loop with the new session
|
||||
continue
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Not a valid JSON control message, treat as regular input
|
||||
await session.write_input(text.encode("utf-8"))
|
||||
else:
|
||||
await session.write_input(text.encode("utf-8"))
|
||||
elif message["type"] == "websocket.disconnect":
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
||||
"""Send periodic ping messages to detect disconnections."""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(30) # Ping every 30 seconds
|
||||
try:
|
||||
await websocket.send_json({"type": "ping"})
|
||||
except Exception:
|
||||
# WebSocket is closed or broken
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset",
|
||||
summary="Reset terminal session",
|
||||
description="Reset the terminal session for a tool instance, killing the current shell and starting fresh.",
|
||||
)
|
||||
async def reset_terminal_session(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Reset the terminal session for an instance.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project.
|
||||
repo_id: UUID of the repository.
|
||||
instance_id: UUID of the tool instance.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with status message.
|
||||
"""
|
||||
# Get instance and verify it exists and is running
|
||||
instance = await db_session.get(ToolInstance, instance_id)
|
||||
if instance is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Instance not found"
|
||||
)
|
||||
|
||||
if instance.status != "running" or not instance.container_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Instance is not running"
|
||||
)
|
||||
|
||||
# Fetch tool type to get startup_command
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
|
||||
try:
|
||||
# Reset the session
|
||||
new_session = await terminal_manager.reset_session(
|
||||
instance_id,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
)
|
||||
|
||||
logger.info("Terminal session reset for instance %s (new session_id=%s)", instance_id, new_session.session_id)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Terminal session reset successfully",
|
||||
"instance_id": str(instance_id),
|
||||
"session_id": new_session.session_id,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error("Failed to reset terminal session for instance %s: %s", instance_id, str(exc), exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to reset terminal session: {exc}"
|
||||
)
|
||||
|
||||
|
||||
async def _get_user_from_websocket(
|
||||
websocket: WebSocket,
|
||||
db_session: AsyncSession,
|
||||
) -> uuid.UUID | None:
|
||||
"""Extract and validate user ID from session cookie in WebSocket.
|
||||
|
||||
Args:
|
||||
websocket: The WebSocket connection.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
The user's UUID if authenticated, None otherwise.
|
||||
"""
|
||||
from src.auth.session import decode_session_cookie
|
||||
from src.config import Settings
|
||||
|
||||
session_cookie = websocket.cookies.get("session")
|
||||
if not session_cookie:
|
||||
return None
|
||||
|
||||
settings = Settings()
|
||||
try:
|
||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||
return uuid.UUID(str(payload["user_id"]))
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
@@ -0,0 +1,26 @@
|
||||
# apps/api/src/api/tool (index)
|
||||
dir: apps/api/src/api/tool
|
||||
|
||||
## role
|
||||
Provides FastAPI REST API endpoints for managing Docker-based tools, including their definitions, types, instances, lifecycle operations, and active sessions.
|
||||
## parent
|
||||
index: apps/api/src/api/.pi-map.index.md
|
||||
map: apps/api/src/api/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- sessions.py
|
||||
- tool_definitions.py
|
||||
- tool_instances.py
|
||||
- tool_lifecycle.py
|
||||
- tool_types.py
|
||||
- tool_types_validation.py
|
||||
## links
|
||||
index: apps/api/src/api/tool/.pi-map.index.md
|
||||
map: apps/api/src/api/tool/.pi-map.md
|
||||
## workflows
|
||||
- change tool behavior
|
||||
read: __init__.py, sessions.py, tool_definitions.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,33 @@
|
||||
# apps/api/src/api/tool
|
||||
dir: apps/api/src/api/tool
|
||||
|
||||
index: apps/api/src/api/tool/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides FastAPI REST API endpoints for managing Docker-based tools, including their definitions, types, instances, lifecycle operations, and active sessions.
|
||||
## files
|
||||
- __init__.py | Aggregates and exports all tool-related API routers from submodules as a unified module interface. | dep: src.api.tool.sessions, src.api.tool.tool_definitions, src.api.tool.tool_instances, src.api.tool.tool_lifecycle, src.api.tool.tool_types
|
||||
- sessions.py | Provides a FastAPI endpoint to retrieve all active tool instances (sessions) for the currently authenticated user with related metadata. | exp: func:get_user_sessions(user_id, session) → dict, call:_get_user, call:session.execute, call:select(ToolInstance) .where(ToolInstance.owner_id == user_id) .where( ToolInstance.status.in_( ["running", "building", "pending", "stopped", "error"] ) ) .order_by, call:ToolInstance.status.in_, call:ToolInstance.created_at.desc, call:result.scalars().all, call:session.get, call:sessions.append, call:str, call:instance.created_at.isoformat | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.models (GitRepository, Project, ToolInstance, ToolType, Workspace)
|
||||
- tool_definitions.py | FastAPI router providing CRUD endpoints and manifest compilation for tool definition manifests stored in a database. | exp: class:CreateToolDefinitionRequest, class:UpdateToolDefinitionRequest, func:create_tool_definition(data: CreateToolDefinitionRequest, user_id, session) → dict, call:uuid.UUID, call:session.get, call:session.execute, call:select(ToolDefinitionManifest).where, call:existing.scalar_one_or_none, call:ToolDefinitionManifest, call:session.add, call:session.commit, call:session.refresh, call:logger.info, call:str, call:definition.created_at.isoformat, raise:HTTPException, func:list_tool_definitions(user_id, session, include_bases) → dict, call:select, call:query.where, call:ToolDefinitionManifest.is_base.is_, call:session.execute, call:query.order_by, call:ToolDefinitionManifest.created_at.desc, call:result.scalars().all, call:str, call:d.created_at.isoformat, func:get_tool_definition(definition_id: uuid.UUID, user_id, session) → dict, call:session.get, call:str, call:definition.created_at.isoformat, call:definition.updated_at.isoformat, raise:HTTPException, func:update_tool_definition(definition_id: uuid.UUID, data: UpdateToolDefinitionRequest, user_id, session) → dict, call:session.get, call:session.commit, call:session.refresh, call:logger.info, call:str, call:definition.updated_at.isoformat, raise:HTTPException, func:delete_tool_definition(definition_id: uuid.UUID, user_id, session) → dict, call:session.get, call:session.execute, call:select(ToolType).where, call:result.scalars().all, call:", ".join, call:session.delete, call:session.commit, call:logger.info, call:str, raise:HTTPException, func:compile_tool_definition(definition_id: uuid.UUID, user_id, session) → dict, call:session.get, call:dict, call:resolve_base, call:deep_merge, call:compile_dockerfile, call:compile_entrypoint, call:compute_image_tag, call:compile_compose, call:session.commit, call:str, raise:HTTPException | dep: logging, uuid, fastapi, pydantic, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.build.manifest_compiler
|
||||
- tool_instances.py | Provides FastAPI REST endpoints for managing tool instances including CRUD operations, logs, health checks, tunnel recreation, event history, and HTTP proxying to running containers. | exp: func:create_instance(project_id: uuid.UUID, repo_id: uuid.UUID, data: CreateInstanceRequest, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:create_tool_instance, call:str, call:instance.created_at.isoformat, raise:HTTPException, func:list_instances(project_id: uuid.UUID, repo_id: uuid.UUID, user_id, session) → list[dict], call:_get_user, call:_get_owned_project, call:session.execute, call:select(ToolInstance) .where(ToolInstance.repository_id == repo_id) .where(ToolInstance.owner_id == user_id) .order_by, call:ToolInstance.created_at.desc, call:result.scalars().all, call:str, call:i.created_at.isoformat, func:get_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:session.get, call:get_container_status, call:str, call:instance.created_at.isoformat, call:instance.last_started_at.isoformat, raise:HTTPException, func:rename_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:data.get("display_name", "").strip, call:rename_tool_instance, call:str, raise:HTTPException, func:get_instance_logs(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, tail, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:session.get, call:get_container_logs, raise:HTTPException, func:recreate_tunnel_endpoint(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:recreate_instance_tunnel, raise:HTTPException, func:check_instance_tunnel_health(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:session.get, call:get_container_status, call:instance.probe_result.get, call:"\n".join, call:check_tunnel_health, call:tunnel_health.get, raise:HTTPException, func:get_instance_events(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, limit, user_id, session) → list[dict], call:_get_user, call:_get_owned_project, call:session.get, call:session.execute, call:select(InstanceEvent) .where(InstanceEvent.instance_id == instance_id) .order_by(InstanceEvent.created_at.desc()) .limit, call:InstanceEvent.created_at.desc, call:result.scalars().all, call:str, call:row.created_at.isoformat, raise:HTTPException, func:proxy_to_instance(request: Request, project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, path, user_id, session) → Response, call:session.get, call:str, call:dict, call:headers.pop, call:httpx.AsyncClient, call:request.body, call:client.request, call:logger.error, call:response_headers.pop, call:Response, raise:HTTPException | dep: logging, uuid, httpx, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.docker, src.services.shared.tunnel, src.schemas.tool, src.services.tool.instance_service
|
||||
- tool_lifecycle.py | FastAPI router providing REST endpoints for managing Docker-based tool instance lifecycle operations (start, stop, restart, delete). | exp: func:start_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, data, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:start_tool_instance, raise:HTTPException, func:stop_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:stop_tool_instance, raise:HTTPException, func:restart_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:_get_owned_project, call:restart_tool_instance, raise:HTTPException, func:delete_instance(project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, force, user_id, session) → None, call:_get_user, call:_get_owned_project, call:delete_tool_instance, call:str, call:detail.lower, raise:HTTPException | dep: logging, uuid, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.schemas.tool, src.services.tool.instance_service
|
||||
- tool_types.py | Defines FastAPI routes for CRUD operations and validation of Docker tool types with compose/dockerfile/manifest definitions. | exp: func:_require_admin(user: User) → None, func:create_tool_type(data: ToolTypeCreate, user_id, session) → ToolType, call:_get_user, call:_require_admin, call:session.scalar, call:select(ToolType).where, call:ToolType, call:session.add, call:session.commit, call:session.refresh, raise:HTTPException, func:list_tool_types(user_id, session) → list[ToolType], call:_get_user, call:session.execute, call:select(ToolType).order_by, call:list, call:result.scalars().all, func:get_tool_type(tool_type_id: uuid.UUID, user_id, session) → ToolType, call:_get_user, call:session.get, raise:HTTPException, func:update_tool_type(tool_type_id: uuid.UUID, data: ToolTypeUpdate, user_id, session) → ToolType, call:_get_user, call:_require_admin, call:session.get, call:data.model_dump, call:update_data.get, call:validate_compose_yaml, call:check_port_exposed, call:validate_required_variables, call:update_data.items, call:setattr, call:session.commit, call:session.refresh, raise:HTTPException, func:validate_tool_type_template(data: ToolTypeValidateRequest, user_id, session) → dict, call:_get_user, call:errors.append, call:validate_compose_yaml, call:str, call:data.dockerfile_template.strip().startswith, call:len, func:validate_tool_type(tool_type_id: uuid.UUID, user_id, session) → dict, call:_get_user, call:session.get, call:errors.append, call:validate_compose_yaml, call:str, call:tool_type.dockerfile_template.strip().startswith, call:len, raise:HTTPException, func:delete_tool_type(tool_type_id: uuid.UUID, user_id, session) → None, call:_get_user, call:_require_admin, call:session.get, call:session.delete, call:session.commit, raise:HTTPException | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.api.tool.tool_types_validation, src.auth.dependencies, src.models, src.models.user, src.schemas.tool
|
||||
- tool_types_validation.py | Validates Docker Compose YAML templates by sanitizing template variables, parsing YAML structure, checking port exposure, and verifying required variables exist. | exp: func:sanitize_template_vars(template: str) → str, call:re.sub, func:validate_compose_yaml(template: str) → dict, call:sanitize_template_vars, call:yaml.safe_load, call:isinstance, raise:ValueError, func:check_port_exposed(parsed: dict, port: int) → bool, call:str, call:isinstance, call:parsed["services"].values, call:port_mapping.split, call:mapping.split, func:validate_required_variables(template: str, variables: list[str]) → None, raise:HTTPException | dep: re, yaml, fastapi
|
||||
## arch
|
||||
Modular FastAPI router organization with clear separation of concerns across CRUD operations, Docker lifecycle management, YAML/Compose validation, and container proxying/tunneling.
|
||||
## tags
|
||||
get, call:, tool, raise:httpexception, user, instance, call:str, call:session.get
|
||||
## symbols
|
||||
- CreateToolDefinitionRequest
|
||||
- UpdateToolDefinitionRequest
|
||||
- get_user_sessions
|
||||
- create_tool_definition
|
||||
- list_tool_definitions
|
||||
- get_tool_definition
|
||||
- update_tool_definition
|
||||
- delete_tool_definition
|
||||
## workflows
|
||||
- change tool behavior
|
||||
read: __init__.py, sessions.py, tool_definitions.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Tool API routers module."""
|
||||
|
||||
from src.api.tool.sessions import sessions_router
|
||||
from src.api.tool.tool_definitions import router as tool_definitions_router
|
||||
from src.api.tool.tool_instances import router as tool_instances_router
|
||||
from src.api.tool.tool_lifecycle import router as tool_lifecycle_router
|
||||
from src.api.tool.tool_types import router as tool_types_router
|
||||
|
||||
__all__ = [
|
||||
"sessions_router",
|
||||
"tool_definitions_router",
|
||||
"tool_instances_router",
|
||||
"tool_lifecycle_router",
|
||||
"tool_types_router",
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Sessions API endpoints (running instances for current user)."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.models import GitRepository
|
||||
from src.models import Project
|
||||
from src.models import ToolInstance
|
||||
from src.models import ToolType
|
||||
|
||||
sessions_router = APIRouter(prefix="/users", tags=["sessions"])
|
||||
|
||||
|
||||
@sessions_router.get(
|
||||
"/me/sessions",
|
||||
summary="Get user sessions",
|
||||
description="Get all active sessions (running instances) for the current user.",
|
||||
)
|
||||
async def get_user_sessions(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get all active sessions for the current user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary containing list of active sessions with instance details.
|
||||
"""
|
||||
_user = await _get_user(session, user_id)
|
||||
|
||||
result = await session.execute(
|
||||
select(ToolInstance)
|
||||
.where(ToolInstance.owner_id == user_id)
|
||||
.where(
|
||||
ToolInstance.status.in_(
|
||||
["running", "building", "pending", "stopped", "error"]
|
||||
)
|
||||
)
|
||||
.order_by(ToolInstance.created_at.desc())
|
||||
)
|
||||
instances = result.scalars().all()
|
||||
|
||||
sessions = []
|
||||
for instance in instances:
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
repo = await session.get(GitRepository, instance.repository_id)
|
||||
project = await session.get(Project, instance.project_id)
|
||||
|
||||
workspace_name = None
|
||||
if instance.workspace_id:
|
||||
from src.models import Workspace as WorkspaceModel
|
||||
workspace = await session.get(WorkspaceModel, instance.workspace_id)
|
||||
if workspace:
|
||||
workspace_name = workspace.name
|
||||
|
||||
sessions.append(
|
||||
{
|
||||
"id": str(instance.id),
|
||||
"display_name": instance.display_name,
|
||||
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||
"tool_icon": tool_type.name if tool_type else "code",
|
||||
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
|
||||
"repository_name": repo.name if repo else "unknown",
|
||||
"repository_id": str(instance.repository_id),
|
||||
"project_name": project.name if project else "unknown",
|
||||
"project_id": str(instance.project_id),
|
||||
"workspace_name": workspace_name,
|
||||
"status": instance.status,
|
||||
"url": instance.url,
|
||||
"selected_config_profile_id": str(instance.selected_config_profile_id)
|
||||
if instance.selected_config_profile_id
|
||||
else None,
|
||||
"created_at": instance.created_at.isoformat()
|
||||
if instance.created_at
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
return {"sessions": sessions}
|
||||
@@ -9,9 +9,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||
from src.models.tool_type import ToolType
|
||||
from src.services.manifest_compiler import (
|
||||
from src.models import ToolDefinitionManifest
|
||||
from src.models import ToolType
|
||||
from src.services.build.manifest_compiler import (
|
||||
compile_compose,
|
||||
compile_dockerfile,
|
||||
compile_entrypoint,
|
||||
@@ -173,7 +173,7 @@ async def list_tool_definitions(
|
||||
"""
|
||||
query = select(ToolDefinitionManifest)
|
||||
if not include_bases:
|
||||
query = query.where(ToolDefinitionManifest.is_base == False)
|
||||
query = query.where(ToolDefinitionManifest.is_base.is_(False))
|
||||
|
||||
result = await session.execute(
|
||||
query.order_by(ToolDefinitionManifest.created_at.desc())
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Tool instance API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import (
|
||||
_get_owned_project,
|
||||
_get_user,
|
||||
get_current_user_id,
|
||||
get_db_session,
|
||||
)
|
||||
from src.models import ToolInstance
|
||||
from src.services.docker import get_container_logs, get_container_status
|
||||
from src.services.shared.tunnel import check_tunnel_health
|
||||
from src.schemas.tool import CreateInstanceRequest
|
||||
from src.services.tool.instance_service import (
|
||||
create_tool_instance,
|
||||
recreate_instance_tunnel,
|
||||
rename_tool_instance,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories/{repo_id}/instances",
|
||||
summary="Create tool instance",
|
||||
description="Create a new tool instance for a repository.",
|
||||
)
|
||||
async def create_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: CreateInstanceRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
try:
|
||||
instance = await create_tool_instance(session, user_id, project_id, repo_id, data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
)
|
||||
return {
|
||||
"id": str(instance.id),
|
||||
"name": instance.name,
|
||||
"display_name": instance.display_name,
|
||||
"tool_type_id": str(instance.tool_type_id),
|
||||
"status": instance.status,
|
||||
"workspace_id": str(instance.workspace_id) if instance.workspace_id else None,
|
||||
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
||||
"created_at": instance.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories/{repo_id}/instances",
|
||||
summary="List instances",
|
||||
description="List all tool instances for a repository.",
|
||||
)
|
||||
async def list_instances(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[dict]:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
result = await session.execute(
|
||||
select(ToolInstance)
|
||||
.where(ToolInstance.repository_id == repo_id)
|
||||
.where(ToolInstance.owner_id == user_id)
|
||||
.order_by(ToolInstance.created_at.desc())
|
||||
)
|
||||
instances = result.scalars().all()
|
||||
|
||||
return [
|
||||
{"id": str(i.id), "name": i.name, "display_name": i.display_name,
|
||||
"tool_type_id": str(i.tool_type_id), "status": i.status, "url": i.url,
|
||||
"port": i.port, "container_id": i.container_id,
|
||||
"created_at": i.created_at.isoformat() if i.created_at else None}
|
||||
for i in instances
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
|
||||
summary="Get instance",
|
||||
description="Get details for a specific tool instance.",
|
||||
)
|
||||
async def get_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
instance = await session.get(ToolInstance, instance_id)
|
||||
if instance is None or instance.repository_id != repo_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
docker_status = None
|
||||
if instance.container_id:
|
||||
docker_status = get_container_status(instance.container_id)
|
||||
|
||||
return {
|
||||
"id": str(instance.id),
|
||||
"name": instance.name,
|
||||
"display_name": instance.display_name,
|
||||
"tool_type_id": str(instance.tool_type_id),
|
||||
"status": instance.status,
|
||||
"url": instance.url,
|
||||
"public_url": instance.public_url,
|
||||
"port": instance.port,
|
||||
"container_id": instance.container_id,
|
||||
"container_name": instance.container_name,
|
||||
"compose_path": instance.compose_path,
|
||||
"workspace_id": str(instance.workspace_id) if instance.workspace_id else None,
|
||||
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
||||
"ssh_key_ids": instance.ssh_key_ids,
|
||||
"created_at": instance.created_at.isoformat() if instance.created_at else None,
|
||||
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
||||
"docker_status": docker_status,
|
||||
}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
|
||||
summary="Rename instance",
|
||||
description="Update the display name of a tool instance.",
|
||||
)
|
||||
async def rename_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
display_name = data.get("display_name", "").strip()
|
||||
if not display_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="display_name is required",
|
||||
)
|
||||
|
||||
try:
|
||||
instance = await rename_tool_instance(
|
||||
session, user_id, project_id, repo_id, instance_id, display_name
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(instance.id),
|
||||
"name": instance.name,
|
||||
"display_name": instance.display_name,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs",
|
||||
summary="Get instance logs",
|
||||
description="Get container logs for a tool instance.",
|
||||
)
|
||||
async def get_instance_logs(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
tail: int = 100,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
instance = await session.get(ToolInstance, instance_id)
|
||||
if instance is None or instance.repository_id != repo_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
if not instance.container_id:
|
||||
return {"logs": "No container running"}
|
||||
|
||||
logs = get_container_logs(instance.container_id, tail)
|
||||
return {"logs": logs}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel",
|
||||
summary="Recreate tunnel",
|
||||
description="Recreate the temporary Cloudflare tunnel for a running instance.",
|
||||
)
|
||||
async def recreate_tunnel_endpoint(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
try:
|
||||
return await recreate_instance_tunnel(
|
||||
session, user_id, project_id, repo_id, instance_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
|
||||
summary="Check instance health",
|
||||
description="Check container and tunnel health for an instance.",
|
||||
)
|
||||
async def check_instance_tunnel_health(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
instance = await session.get(ToolInstance, instance_id)
|
||||
if instance is None or instance.repository_id != repo_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
container_info = {"status": "not_found", "exit_code": None, "health": None}
|
||||
if instance.container_id:
|
||||
container_info = get_container_status(instance.container_id)
|
||||
|
||||
response = {
|
||||
"healthy": False,
|
||||
"container_status": container_info["status"],
|
||||
"container_health": container_info["health"],
|
||||
"tunnel_status": "not_applicable",
|
||||
"tunnel_status_code": None,
|
||||
"probe_status": "not_applicable",
|
||||
"last_probe_output": None,
|
||||
"error": None,
|
||||
}
|
||||
if instance.status == "probing":
|
||||
response["probe_status"] = "pending"
|
||||
elif instance.probe_result:
|
||||
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed"
|
||||
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))
|
||||
if instance.url and instance.status in ("running", "unhealthy"):
|
||||
tunnel_health = check_tunnel_health(instance.url)
|
||||
response["tunnel_status"] = tunnel_health["tunnel_status"]
|
||||
response["tunnel_status_code"] = tunnel_health.get("status_code")
|
||||
if tunnel_health.get("error"):
|
||||
response["error"] = tunnel_health["error"]
|
||||
container_healthy = container_info["status"] == "running"
|
||||
if instance.url:
|
||||
response["healthy"] = container_healthy and response["tunnel_status"] == "healthy"
|
||||
else:
|
||||
response["healthy"] = container_healthy
|
||||
if not container_healthy:
|
||||
response["error"] = f"Container is {container_info['status']}"
|
||||
if container_info["exit_code"] is not None:
|
||||
response["error"] += f" (exit code: {container_info['exit_code']})"
|
||||
return response
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/events",
|
||||
summary="Get instance events history",
|
||||
description="Get lifecycle event history for a tool instance.",
|
||||
)
|
||||
async def get_instance_events(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
limit: int = 50,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[dict]:
|
||||
from sqlalchemy import select
|
||||
from src.models import InstanceEvent
|
||||
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
instance = await session.get(ToolInstance, instance_id)
|
||||
if instance is None or instance.repository_id != repo_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(InstanceEvent)
|
||||
.where(InstanceEvent.instance_id == instance_id)
|
||||
.order_by(InstanceEvent.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(row.id),
|
||||
"event_type": row.event_type,
|
||||
"status": row.status,
|
||||
"message": row.message,
|
||||
"metadata": row.event_metadata,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
|
||||
summary="Proxy to instance",
|
||||
description="Proxy HTTP requests to a running tool instance.",
|
||||
)
|
||||
async def proxy_to_instance(
|
||||
request: Request,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
path: str = "",
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
instance = await session.get(ToolInstance, instance_id)
|
||||
if instance is None or instance.repository_id != repo_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
if instance.owner_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="not authorized to access this instance",
|
||||
)
|
||||
|
||||
if instance.status != "running" or not instance.container_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="instance is not running",
|
||||
)
|
||||
|
||||
target_url = f"http://{instance.container_name}:{instance.port}"
|
||||
if path:
|
||||
target_url += f"/{path}"
|
||||
|
||||
query_string = str(request.query_params)
|
||||
if query_string:
|
||||
target_url += f"?{query_string}"
|
||||
|
||||
headers = dict(request.headers)
|
||||
headers.pop("host", None)
|
||||
headers.pop("cookie", None)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
body = await request.body()
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
content=body,
|
||||
follow_redirects=False,
|
||||
timeout=30.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Proxy error: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"failed to reach instance: {exc}",
|
||||
)
|
||||
|
||||
response_headers = dict(response.headers)
|
||||
for header in ["content-encoding", "transfer-encoding", "connection"]:
|
||||
response_headers.pop(header, None)
|
||||
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Tool instance lifecycle API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import (
|
||||
_get_owned_project,
|
||||
_get_user,
|
||||
get_current_user_id,
|
||||
get_db_session,
|
||||
)
|
||||
from src.schemas.tool import StartInstanceRequest
|
||||
from src.services.tool.instance_service import (
|
||||
delete_tool_instance,
|
||||
restart_tool_instance,
|
||||
start_tool_instance,
|
||||
stop_tool_instance,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/start",
|
||||
summary="Start instance",
|
||||
description="Start a tool instance using Docker Compose.",
|
||||
)
|
||||
async def start_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
data: StartInstanceRequest | None = None,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
try:
|
||||
return await start_tool_instance(
|
||||
session, user_id, project_id, repo_id, instance_id, data
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop",
|
||||
summary="Stop instance",
|
||||
description="Stop a running tool instance.",
|
||||
)
|
||||
async def stop_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
try:
|
||||
return await stop_tool_instance(session, user_id, project_id, repo_id, instance_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart",
|
||||
summary="Restart instance",
|
||||
description="Restart a tool instance.",
|
||||
)
|
||||
async def restart_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
try:
|
||||
return await restart_tool_instance(
|
||||
session, user_id, project_id, repo_id, instance_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
|
||||
summary="Delete instance",
|
||||
description="Delete a tool instance and remove its Docker containers and files.",
|
||||
)
|
||||
async def delete_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
force: bool = False,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
try:
|
||||
await delete_tool_instance(
|
||||
session, user_id, project_id, repo_id, instance_id, force
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
except RuntimeError as exc:
|
||||
detail = str(exc)
|
||||
if "uncommitted changes" in detail.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"message": "Repository has uncommitted changes",
|
||||
"changed_files": detail,
|
||||
"force_required": True,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail
|
||||
)
|
||||
@@ -1,19 +1,23 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.tool_types_validation import (
|
||||
from src.api.tool.tool_types_validation import (
|
||||
check_port_exposed,
|
||||
validate_compose_yaml,
|
||||
validate_required_variables,
|
||||
)
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models import ToolType
|
||||
from src.models.user import User
|
||||
from src.schemas.tool import (
|
||||
ToolTypeCreate,
|
||||
ToolTypeResponse,
|
||||
ToolTypeUpdate,
|
||||
ToolTypeValidateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
||||
|
||||
@@ -29,203 +33,6 @@ async def _require_admin(user: User) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class ToolTypeCreate(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
description: str | None = None
|
||||
default_port: int = 0
|
||||
definition_type: str = "compose"
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
build_context: dict | None = None
|
||||
readiness_probe: dict | None = None
|
||||
startup_command: str | None = None
|
||||
required_variables: list[str] = []
|
||||
category: str = "other"
|
||||
interface_type: str = "web"
|
||||
requires_port: bool = True
|
||||
|
||||
@field_validator("definition_type")
|
||||
@classmethod
|
||||
def validate_definition_type(cls, v: str) -> str:
|
||||
if v not in ("compose", "dockerfile"):
|
||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||
return v
|
||||
|
||||
@field_validator("compose_template")
|
||||
@classmethod
|
||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||
data = info.data
|
||||
if data.get("definition_type") != "compose":
|
||||
return v
|
||||
|
||||
if v is None:
|
||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||
|
||||
validate_compose_yaml(v)
|
||||
return v
|
||||
|
||||
@field_validator("dockerfile_template")
|
||||
@classmethod
|
||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||
data = info.data
|
||||
if data.get("definition_type") != "dockerfile":
|
||||
return v
|
||||
|
||||
if v is None:
|
||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||
|
||||
if not v.strip().startswith("FROM"):
|
||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("interface_type")
|
||||
@classmethod
|
||||
def validate_interface_type(cls, v: str) -> str:
|
||||
if v not in ("web", "terminal"):
|
||||
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||
return v
|
||||
|
||||
@field_validator("default_port")
|
||||
@classmethod
|
||||
def validate_default_port(cls, v: int, info) -> int:
|
||||
data = info.data
|
||||
requires_port = data.get("requires_port", True)
|
||||
if not requires_port:
|
||||
return v
|
||||
if v <= 0 or v > 65535:
|
||||
raise ValueError("Port must be between 1 and 65535")
|
||||
return v
|
||||
|
||||
@field_validator("required_variables")
|
||||
@classmethod
|
||||
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
||||
if not v:
|
||||
return v
|
||||
|
||||
data = info.data
|
||||
if data.get("definition_type") != "compose":
|
||||
return v
|
||||
|
||||
template = data.get("compose_template")
|
||||
if not template:
|
||||
return v
|
||||
|
||||
for var in v:
|
||||
placeholder = f"{{{{{var}}}}}"
|
||||
if placeholder not in template:
|
||||
raise ValueError(f"Required variable '{var}' not found in compose template")
|
||||
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_templates(self) -> "ToolTypeCreate":
|
||||
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
|
||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||
if self.definition_type == "compose" and self.compose_template is None:
|
||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||
|
||||
# Validate that default_port is exposed in compose template (only if requires_port)
|
||||
if self.requires_port and self.definition_type == "compose" and self.compose_template:
|
||||
try:
|
||||
parsed = validate_compose_yaml(self.compose_template)
|
||||
except ValueError:
|
||||
return self
|
||||
|
||||
if not check_port_exposed(parsed, self.default_port):
|
||||
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class ToolTypeUpdate(BaseModel):
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
default_port: int | None = None
|
||||
definition_type: str | None = None
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
build_context: dict | None = None
|
||||
readiness_probe: dict | None = None
|
||||
startup_command: str | None = None
|
||||
required_variables: list[str] | None = None
|
||||
category: str | None = None
|
||||
interface_type: str | None = None
|
||||
requires_port: bool | None = None
|
||||
|
||||
@field_validator("definition_type")
|
||||
@classmethod
|
||||
def validate_definition_type(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v not in ("compose", "dockerfile"):
|
||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||
return v
|
||||
|
||||
@field_validator("interface_type")
|
||||
@classmethod
|
||||
def validate_interface_type(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v not in ("web", "terminal"):
|
||||
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||
return v
|
||||
|
||||
@field_validator("compose_template")
|
||||
@classmethod
|
||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
data = info.data
|
||||
definition_type = data.get("definition_type")
|
||||
if definition_type and definition_type != "compose":
|
||||
return v
|
||||
|
||||
validate_compose_yaml(v)
|
||||
return v
|
||||
|
||||
@field_validator("dockerfile_template")
|
||||
@classmethod
|
||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
data = info.data
|
||||
definition_type = data.get("definition_type")
|
||||
if definition_type and definition_type != "dockerfile":
|
||||
return v
|
||||
|
||||
if not v.strip().startswith("FROM"):
|
||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class ToolTypeResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
display_name: str
|
||||
description: str | None
|
||||
category: str
|
||||
interface_type: str
|
||||
requires_port: bool
|
||||
default_port: int
|
||||
definition_type: str
|
||||
compose_template: str | None
|
||||
dockerfile_template: str | None
|
||||
build_context: dict | None
|
||||
readiness_probe: dict | None
|
||||
startup_command: str | None
|
||||
required_variables: list[str]
|
||||
created_by_id: uuid.UUID | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ToolTypeResponse,
|
||||
@@ -250,18 +57,22 @@ async def create_tool_type(
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
await _require_admin(user)
|
||||
|
||||
|
||||
# Check for duplicate name
|
||||
existing = await session.scalar(select(ToolType).where(ToolType.name == data.name))
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="tool type with this name already exists")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="tool type with this name already exists",
|
||||
)
|
||||
|
||||
tool_type = ToolType(
|
||||
name=data.name,
|
||||
display_name=data.display_name,
|
||||
description=data.description,
|
||||
default_port=data.default_port,
|
||||
definition_type=data.definition_type,
|
||||
manifest_id=data.manifest_id,
|
||||
compose_template=data.compose_template,
|
||||
dockerfile_template=data.dockerfile_template,
|
||||
build_context=data.build_context,
|
||||
@@ -327,7 +138,9 @@ async def get_tool_type(
|
||||
await _get_user(session, user_id)
|
||||
tool_type = await session.get(ToolType, tool_type_id)
|
||||
if tool_type is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||
)
|
||||
return tool_type
|
||||
|
||||
|
||||
@@ -356,15 +169,17 @@ async def update_tool_type(
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
await _require_admin(user)
|
||||
|
||||
|
||||
tool_type = await session.get(ToolType, tool_type_id)
|
||||
if tool_type is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||
)
|
||||
|
||||
# Built-in tool types can now be modified
|
||||
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
|
||||
# Validate port if being updated
|
||||
requires_port = update_data.get("requires_port", tool_type.requires_port)
|
||||
if "default_port" in update_data and requires_port:
|
||||
@@ -372,9 +187,9 @@ async def update_tool_type(
|
||||
if new_port <= 0 or new_port > 65535:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Port must be between 1 and 65535"
|
||||
detail="Port must be between 1 and 65535",
|
||||
)
|
||||
|
||||
|
||||
# Only validate port exposure for compose definitions
|
||||
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||
if definition_type == "compose":
|
||||
@@ -385,12 +200,11 @@ async def update_tool_type(
|
||||
if not check_port_exposed(parsed, new_port):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Port {new_port} is not exposed in the compose template"
|
||||
detail=f"Port {new_port} is not exposed in the compose template",
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
||||
)
|
||||
|
||||
# Validate required variables for compose definitions
|
||||
@@ -404,21 +218,22 @@ async def update_tool_type(
|
||||
template = tool_type.compose_template
|
||||
if template:
|
||||
validate_required_variables(template, update_data["required_variables"])
|
||||
|
||||
|
||||
# When switching to manifest, clear legacy templates
|
||||
if definition_type == "manifest":
|
||||
if "manifest_id" in update_data:
|
||||
tool_type.manifest_id = update_data["manifest_id"]
|
||||
tool_type.compose_template = None
|
||||
tool_type.dockerfile_template = None
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(tool_type, field, value)
|
||||
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(tool_type)
|
||||
return tool_type
|
||||
|
||||
|
||||
class ToolTypeValidateRequest(BaseModel):
|
||||
definition_type: str
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/validate",
|
||||
summary="Validate tool type template",
|
||||
@@ -458,8 +273,11 @@ async def validate_tool_type_template(
|
||||
elif not data.dockerfile_template.strip().startswith("FROM"):
|
||||
errors.append("Dockerfile must start with a FROM instruction")
|
||||
|
||||
elif data.definition_type == "manifest":
|
||||
pass # Manifest validation is handled separately
|
||||
|
||||
else:
|
||||
errors.append("definition_type must be 'compose' or 'dockerfile'")
|
||||
errors.append("definition_type must be 'compose', 'dockerfile', or 'manifest'")
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
@@ -490,10 +308,12 @@ async def validate_tool_type(
|
||||
await _get_user(session, user_id)
|
||||
tool_type = await session.get(ToolType, tool_type_id)
|
||||
if tool_type is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||
)
|
||||
|
||||
errors = []
|
||||
|
||||
|
||||
if tool_type.definition_type == "compose":
|
||||
if not tool_type.compose_template:
|
||||
errors.append("Compose template is empty")
|
||||
@@ -502,13 +322,17 @@ async def validate_tool_type(
|
||||
validate_compose_yaml(tool_type.compose_template)
|
||||
except ValueError as e:
|
||||
errors.append(str(e))
|
||||
|
||||
|
||||
elif tool_type.definition_type == "dockerfile":
|
||||
if not tool_type.dockerfile_template:
|
||||
errors.append("Dockerfile template is empty")
|
||||
elif not tool_type.dockerfile_template.strip().startswith("FROM"):
|
||||
errors.append("Dockerfile must start with a FROM instruction")
|
||||
|
||||
|
||||
elif tool_type.definition_type == "manifest":
|
||||
if not tool_type.manifest_id:
|
||||
errors.append("Manifest reference is missing")
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
@@ -538,12 +362,14 @@ async def delete_tool_type(
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
await _require_admin(user)
|
||||
|
||||
|
||||
tool_type = await session.get(ToolType, tool_type_id)
|
||||
if tool_type is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||
)
|
||||
|
||||
# Built-in tool types can now be deleted
|
||||
|
||||
|
||||
await session.delete(tool_type)
|
||||
await session.commit()
|
||||
+8
-2
@@ -60,8 +60,14 @@ def check_port_exposed(parsed: dict, port: int) -> bool:
|
||||
for service_config in parsed["services"].values():
|
||||
if isinstance(service_config, dict) and "ports" in service_config:
|
||||
for port_mapping in service_config["ports"]:
|
||||
if isinstance(port_mapping, str) and port_str in port_mapping:
|
||||
return True
|
||||
if isinstance(port_mapping, str):
|
||||
# Port mappings can be "host:container", "ip:host:container",
|
||||
# "container", or ".../protocol". The container port is the
|
||||
# last numeric segment before any protocol suffix.
|
||||
mapping = port_mapping.split("/")[0]
|
||||
parts = mapping.split(":")
|
||||
if parts[-1] == port_str:
|
||||
return True
|
||||
elif isinstance(port_mapping, int) and port_mapping == port:
|
||||
return True
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
"""Tool configuration API endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.tool_config import ToolConfig
|
||||
from src.models.tool_type import ToolType
|
||||
|
||||
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
||||
|
||||
|
||||
class ToolConfigCreate(BaseModel):
|
||||
tool_type_id: str = Field(description="UUID of the tool type")
|
||||
project_id: str | None = Field(default=None, description="Optional project ID for project-scoped config")
|
||||
key: str = Field(description="Config key name")
|
||||
value: str = Field(description="Config value")
|
||||
config_type: str = Field(default="env", description="Type: env or file")
|
||||
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
||||
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
||||
start_command: str | None = Field(default=None, description="Override container start command")
|
||||
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
||||
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
||||
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
||||
|
||||
@field_validator("port_override")
|
||||
@classmethod
|
||||
def validate_port(cls, v: int | None) -> int | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v < 1 or v > 65535:
|
||||
raise ValueError("Port must be between 1 and 65535")
|
||||
return v
|
||||
|
||||
@field_validator("environment_variables")
|
||||
@classmethod
|
||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||
return _validate_env_vars(v)
|
||||
|
||||
@field_validator("volumes")
|
||||
@classmethod
|
||||
def validate_volumes(cls, v: list | None) -> list | None:
|
||||
return _validate_volumes(v)
|
||||
|
||||
|
||||
class ToolConfigUpdate(BaseModel):
|
||||
key: str | None = Field(default=None, description="Config key name")
|
||||
value: str | None = Field(default=None, description="Config value")
|
||||
config_type: str | None = Field(default=None, description="Type: env or file")
|
||||
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
||||
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
||||
start_command: str | None = Field(default=None, description="Override container start command")
|
||||
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
||||
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
||||
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
||||
|
||||
@field_validator("port_override")
|
||||
@classmethod
|
||||
def validate_port(cls, v: int | None) -> int | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v < 1 or v > 65535:
|
||||
raise ValueError("Port must be between 1 and 65535")
|
||||
return v
|
||||
|
||||
@field_validator("environment_variables")
|
||||
@classmethod
|
||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||
return _validate_env_vars(v)
|
||||
|
||||
@field_validator("volumes")
|
||||
@classmethod
|
||||
def validate_volumes(cls, v: list | None) -> list | None:
|
||||
return _validate_volumes(v)
|
||||
|
||||
|
||||
class ToolConfigResponse(BaseModel):
|
||||
id: str
|
||||
tool_type_id: str
|
||||
project_id: str | None
|
||||
key: str
|
||||
value: str
|
||||
config_type: str
|
||||
file_path: str | None
|
||||
port_override: int | None
|
||||
start_command: str | None
|
||||
working_directory: str | None
|
||||
environment_variables: dict | None
|
||||
volumes: list[dict] | None
|
||||
|
||||
|
||||
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
|
||||
async def list_configs(
|
||||
tool_type_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list:
|
||||
"""List tool configs for the current user."""
|
||||
query = select(ToolConfig).where(ToolConfig.user_id == user_id)
|
||||
|
||||
if tool_type_id:
|
||||
query = query.where(ToolConfig.tool_type_id == uuid.UUID(tool_type_id))
|
||||
if project_id:
|
||||
query = query.where(ToolConfig.project_id == uuid.UUID(project_id))
|
||||
else:
|
||||
# If no project specified, get only global configs (project_id is None)
|
||||
query = query.where(ToolConfig.project_id.is_(None))
|
||||
|
||||
result = await session.execute(query)
|
||||
configs = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(c.id),
|
||||
"tool_type_id": str(c.tool_type_id),
|
||||
"project_id": str(c.project_id) if c.project_id else None,
|
||||
"key": c.key,
|
||||
"value": c.value,
|
||||
"config_type": c.config_type,
|
||||
"file_path": c.file_path,
|
||||
"port_override": c.port_override,
|
||||
"start_command": c.start_command,
|
||||
"working_directory": c.working_directory,
|
||||
"environment_variables": c.environment_variables,
|
||||
"volumes": c.volumes,
|
||||
}
|
||||
for c in configs
|
||||
]
|
||||
|
||||
|
||||
@router.post("", summary="Create tool config", description="Create a new tool config.", status_code=status.HTTP_201_CREATED)
|
||||
async def create_config(
|
||||
data: ToolConfigCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Create a tool config."""
|
||||
# Verify tool type exists
|
||||
tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id))
|
||||
if tool_type is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||
|
||||
# Check for existing config with same key
|
||||
query = select(ToolConfig).where(
|
||||
ToolConfig.user_id == user_id,
|
||||
ToolConfig.tool_type_id == uuid.UUID(data.tool_type_id),
|
||||
ToolConfig.key == data.key,
|
||||
)
|
||||
if data.project_id:
|
||||
query = query.where(ToolConfig.project_id == uuid.UUID(data.project_id))
|
||||
else:
|
||||
query = query.where(ToolConfig.project_id.is_(None))
|
||||
|
||||
existing = await session.scalar(query)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"config with key '{data.key}' already exists"
|
||||
)
|
||||
|
||||
config = ToolConfig(
|
||||
user_id=user_id,
|
||||
tool_type_id=uuid.UUID(data.tool_type_id),
|
||||
project_id=uuid.UUID(data.project_id) if data.project_id else None,
|
||||
key=data.key,
|
||||
value=data.value,
|
||||
config_type=data.config_type,
|
||||
file_path=data.file_path,
|
||||
port_override=data.port_override,
|
||||
start_command=data.start_command,
|
||||
working_directory=data.working_directory,
|
||||
environment_variables=data.environment_variables,
|
||||
volumes=data.volumes,
|
||||
)
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
|
||||
return {
|
||||
"id": str(config.id),
|
||||
"tool_type_id": str(config.tool_type_id),
|
||||
"project_id": str(config.project_id) if config.project_id else None,
|
||||
"key": config.key,
|
||||
"value": config.value,
|
||||
"config_type": config.config_type,
|
||||
"file_path": config.file_path,
|
||||
"port_override": config.port_override,
|
||||
"start_command": config.start_command,
|
||||
"working_directory": config.working_directory,
|
||||
"environment_variables": config.environment_variables,
|
||||
"volumes": config.volumes,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.")
|
||||
async def update_config(
|
||||
config_id: uuid.UUID,
|
||||
data: ToolConfigUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Update a tool config."""
|
||||
config = await session.get(ToolConfig, config_id)
|
||||
if config is None or config.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
||||
|
||||
if data.key is not None:
|
||||
config.key = data.key
|
||||
if data.value is not None:
|
||||
config.value = data.value
|
||||
if data.config_type is not None:
|
||||
config.config_type = data.config_type
|
||||
if data.file_path is not None:
|
||||
config.file_path = data.file_path
|
||||
if data.port_override is not None:
|
||||
config.port_override = data.port_override
|
||||
if data.start_command is not None:
|
||||
config.start_command = data.start_command
|
||||
if data.working_directory is not None:
|
||||
config.working_directory = data.working_directory
|
||||
if data.environment_variables is not None:
|
||||
config.environment_variables = data.environment_variables
|
||||
if data.volumes is not None:
|
||||
config.volumes = data.volumes
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
|
||||
return {
|
||||
"id": str(config.id),
|
||||
"tool_type_id": str(config.tool_type_id),
|
||||
"project_id": str(config.project_id) if config.project_id else None,
|
||||
"key": config.key,
|
||||
"value": config.value,
|
||||
"config_type": config.config_type,
|
||||
"file_path": config.file_path,
|
||||
"port_override": config.port_override,
|
||||
"start_command": config.start_command,
|
||||
"working_directory": config.working_directory,
|
||||
"environment_variables": config.environment_variables,
|
||||
"volumes": config.volumes,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/defaults/{tool_type_id}", summary="Get default configs", description="Get suggested default configs for a tool type.")
|
||||
async def get_default_configs(
|
||||
tool_type_id: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get suggested default configs for a tool type."""
|
||||
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
||||
if tool_type is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||
|
||||
# Return suggested defaults based on required_variables
|
||||
defaults = []
|
||||
for var in tool_type.required_variables:
|
||||
defaults.append({
|
||||
"key": var,
|
||||
"value": "",
|
||||
"config_type": "env",
|
||||
"description": f"Required variable: {var}",
|
||||
})
|
||||
|
||||
return {
|
||||
"tool_type_id": tool_type_id,
|
||||
"suggested_configs": defaults,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{config_id}", summary="Delete tool config", description="Delete a tool config.")
|
||||
async def delete_config(
|
||||
config_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Delete a tool config."""
|
||||
config = await session.get(ToolConfig, config_id)
|
||||
if config is None or config.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
||||
|
||||
await session.delete(config)
|
||||
await session.commit()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user