Compare commits
318 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 391dd5ee80 | |||
| d8d44f0c6a | |||
| bf561186fb | |||
| 0afce741eb | |||
| 7603b739cb | |||
| ff8efdd887 | |||
| b289edf5a9 | |||
| 75b67f2a6f | |||
| bcc7486b59 | |||
| 29e765b2be | |||
| d14cdc1151 | |||
| c80dbf9737 | |||
| 5331a0f110 | |||
| b995521e22 | |||
| 5610017f50 | |||
| 61e7d68d71 | |||
| 2247ec47c9 | |||
| 0d6c1926ae | |||
| 900a8e47a5 | |||
| 25e870ba43 | |||
| 16984b7cf6 | |||
| fc52353b2e | |||
| a63a983116 | |||
| 886c863260 | |||
| 3c25fffd49 | |||
| add7c1b500 | |||
| f9f9372ee7 | |||
| ea42165ed2 | |||
| c178225c8b | |||
| b5e961ebe9 | |||
| 5d379c5f8b | |||
| 671540ded9 | |||
| ad26fd9f35 | |||
| 415aecc0dd | |||
| bb38b37ceb | |||
| 6698c20f25 | |||
| 5e06a2a226 | |||
| 49180e4c6d | |||
| 7a5538b53f | |||
| 41d24beade | |||
| d1777b88ad | |||
| 691bbaa87b | |||
| 6191565e80 | |||
| 468f34202a | |||
| 996a858892 | |||
| ff7fcb6e8d | |||
| 3f06224b75 | |||
| a1a77c99a6 | |||
| 3c96c7b153 | |||
| 10955dfe8e | |||
| 6d7f538a79 | |||
| b32fea671f | |||
| 19f91c085e | |||
| 155d950556 | |||
| 9f720930ea | |||
| 428c70beaa | |||
| 1c6dbcede8 | |||
| 3e59a257dc | |||
| 93da9b42a2 | |||
| 0dd5f2317b | |||
| ab93863ea9 | |||
| 85627793b4 | |||
| 8b6a4f7712 | |||
| bf698b3ff2 | |||
| d5119f29f6 | |||
| 563ff13969 | |||
| 39bac24100 | |||
| 889c598129 | |||
| ac56c5e391 | |||
| 17f4e91a84 | |||
| b3f47310c3 | |||
| 972dae64f2 | |||
| cbd5533adf | |||
| 6e419f815d | |||
| 4f4939406f | |||
| f47cf27720 | |||
| 6559c2a1d9 | |||
| 5d5b39bec1 | |||
| 38d116dcf7 | |||
| da3f8da3ac | |||
| 454a7845bb | |||
| 50e3af0d91 | |||
| 8e1af41cd3 | |||
| 355b471067 | |||
| a2f0847aad | |||
| 82ff8b8801 | |||
| a6ad8268b9 | |||
| 3358af57c2 | |||
| 6a61669294 | |||
| 29e48cdb65 | |||
| ed1f7805f6 | |||
| 1d345eba32 | |||
| e35e605914 | |||
| 94137c6586 | |||
| 1658767cf4 | |||
| 83928d0f02 | |||
| 8f648264f1 | |||
| b26ed7c3e4 | |||
| 6e33e8e4e9 | |||
| f0ae9483f3 | |||
| 90992e46a8 | |||
| 41f9427224 | |||
| 089d802f1d | |||
| a4e6c46a47 | |||
| 47de2a0133 | |||
| bd94cc9bbf | |||
| fe82a248ec | |||
| c8db6ce933 | |||
| 896674195c | |||
| ddd92e3dd4 | |||
| b4203a4a09 | |||
| 5fc8e035e6 | |||
| e6114ed18c | |||
| ac9f7a9299 | |||
| 84cf423684 | |||
| 4c14966ae3 | |||
| 6c31ef9577 | |||
| a12d6a8169 | |||
| 13b3c60abc | |||
| 23fc0a6b82 | |||
| 8b15689fb3 | |||
| 98d4393387 | |||
| 35f0a3ea2e | |||
| 1ae8d0e45f | |||
| 025ccc5817 | |||
| 350b393457 | |||
| 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 |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"fingerprint": "fdea8a74bb4c7449c01c4bd61646c895b10ede78"
|
"fingerprint": "e7b3130f52a328d4051e75364e5394ac63df60c6"
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,12 @@
|
|||||||
|
|
||||||
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
||||||
|
|
||||||
Last updated: 2026-05-28
|
Last updated: 2026-06-17
|
||||||
|
|
||||||
## Sources scanned
|
## Sources scanned
|
||||||
|
|
||||||
- .opencode/skills
|
- .opencode/skills
|
||||||
- .claude/skills
|
- .claude/skills
|
||||||
- /home/alex/.config/opencode/skills
|
|
||||||
|
|
||||||
## Contract
|
## Contract
|
||||||
|
|
||||||
@@ -20,13 +19,11 @@ Last updated: 2026-05-28
|
|||||||
|
|
||||||
| Skill | Trigger / description | Scope | Path |
|
| Skill | Trigger / description | Scope | Path |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
|
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/user/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
|
||||||
| `openspec` | Use OpenSpec as the source of truth for planning, implementation, verification, and archive discipline. | user | `/home/alex/.config/opencode/skills/openspec/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 | `/home/user/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
|
||||||
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-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 | `/home/user/headquarter/.opencode/skills/openspec-explore/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 | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/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 | `/home/user/headquarter/.opencode/skills/openspec-propose/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 | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/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 | `/home/user/headquarter/.claude/skills/sift-backlog/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 | `/home/alex/projects/headquarter/.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 | `/home/alex/projects/headquarter/.claude/skills/sift-backlog/SKILL.md` |
|
|
||||||
|
|
||||||
## Loading protocol
|
## Loading protocol
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# .claude (index)
|
||||||
|
dir: .claude
|
||||||
|
|
||||||
|
## role
|
||||||
|
Configuration directory for Claude AI assistant settings 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 settings and custom instructions.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Simple flat-file configuration storage with no executable code, using human-readable text files for AI behavior customization.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# .claude/skills (index)
|
||||||
|
dir: .claude/skills
|
||||||
|
|
||||||
|
## role
|
||||||
|
Contains skill definitions and configuration files for the Claude AI assistant to extend its capabilities with specialized behaviors and domain-specific expertise.
|
||||||
|
## 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 skill definitions and configuration files for the Claude AI assistant to extend its capabilities with specialized behaviors and domain-specific expertise.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Declarative skill configuration system using structured metadata files (likely YAML/JSON) that define prompts, tools, and context boundaries for modular AI capability extensions.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .claude/skills/sift-backlog (index)
|
||||||
|
dir: .claude/skills/sift-backlog
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides 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
|
||||||
|
Provides 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 CLI (task, plan, dependency, update subcommands)
|
||||||
|
## arch
|
||||||
|
Skill-based workflow automation using markdown-defined CLI tooling and structured task management patterns.
|
||||||
|
## tags
|
||||||
|
skill, defines, workflow, triaging, organizing, activating, backlog, tasks
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -17,6 +17,7 @@ __pycache__/
|
|||||||
*.so
|
*.so
|
||||||
.python-version
|
.python-version
|
||||||
.venv/
|
.venv/
|
||||||
|
.venv-test/
|
||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
@@ -54,3 +55,6 @@ Thumbs.db
|
|||||||
.atl/
|
.atl/
|
||||||
.sisyphus/
|
.sisyphus/
|
||||||
.pi-lens/
|
.pi-lens/
|
||||||
|
minerv3/
|
||||||
|
.cache/
|
||||||
|
openspec-audit-report.md
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# .opencode (index)
|
||||||
|
dir: .opencode
|
||||||
|
|
||||||
|
## role
|
||||||
|
This appears to be a configuration or metadata directory for the OpenCode AI coding assistant tool.
|
||||||
|
## 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
|
||||||
|
This appears to be a configuration or metadata directory for the OpenCode AI coding assistant tool.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Hidden directory storing tool-specific state; no discernible software architecture as it contains no source files.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# .opencode/commands (index)
|
||||||
|
dir: .opencode/commands
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines AI assistant command workflows for an OpenSpec-based development system, covering task execution, change archival, exploratory analysis, and new change proposal generation.
|
||||||
|
## 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 AI assistant command workflows for an OpenSpec-based development system, covering task execution, change archival, exploratory analysis, and new change proposal generation.
|
||||||
|
## files
|
||||||
|
- opsx-apply.md | Implement tasks from an OpenSpec change by selecting a change, reading context files, and executing pending tasks in a loop until completion or blocked. | dep: openspec CLI, AskUserQuestion tool, filesystem access
|
||||||
|
- opsx-archive.md | Defines a workflow for archiving completed changes in an experimental openspec-based development system, including validation, spec sync assessment, and user confirmation steps. | dep: openspec CLI, AskUserQuestion tool, Task tool, Skill tool, filesystem operations
|
||||||
|
- opsx-explore.md | Defines the "explore mode" stance and guidelines for an AI assistant to think through ideas, investigate problems, and clarify requirements without implementing code. | dep: OpenSpec system, ASCII diagrams, codebase investigation tools
|
||||||
|
- opsx-propose.md | Defines an AI assistant workflow for proposing new changes by creating a scaffolded change directory and generating all required artifacts (proposal.md, design.md, tasks.md) in dependency order using the openspec CLI tool. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool
|
||||||
|
## arch
|
||||||
|
Markdown-based command specifications using a structured workflow pattern with sequential steps, file I/O operations, CLI tool integration (openspec), and human-in-the-loop decision points for validation and confirmation.
|
||||||
|
## tags
|
||||||
|
opsx, tasks, openspec, change, openspec cli, askuserquestion tool, defines, explore
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# .opencode/skills (index)
|
||||||
|
dir: .opencode/skills
|
||||||
|
|
||||||
|
## role
|
||||||
|
Contains custom AI agent skills or capabilities that extend the base functionality of the OpenCode framework with specialized tools and behaviors.
|
||||||
|
## 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 custom AI agent skills or capabilities that extend the base functionality of the OpenCode framework with specialized tools and behaviors.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Modular skill plugin architecture where individual capabilities are registered as discrete, composable units that can be dynamically loaded and invoked by the agent system.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-apply-change (index)
|
||||||
|
dir: .opencode/skills/openspec-apply-change
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides an AI assistant skill that automates implementation of OpenSpec specification changes through a structured, CLI-driven workflow.
|
||||||
|
## 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
|
||||||
|
Provides an AI assistant skill that automates implementation of OpenSpec specification changes through a structured, CLI-driven workflow.
|
||||||
|
## files
|
||||||
|
- SKILL.md | Defines an AI assistant skill for implementing tasks from an OpenSpec change using a spec-driven workflow with the openspec CLI. | dep: openspec CLI, AskUserQuestion tool, filesystem access
|
||||||
|
## arch
|
||||||
|
Skill-based plugin architecture using markdown-defined capabilities with structured workflow steps (analyze, plan, implement, verify) and external CLI tool integration.
|
||||||
|
## tags
|
||||||
|
skill, openspec, defines, assistant, implementing, tasks, change, spec
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-archive-change (index)
|
||||||
|
dir: .opencode/skills/openspec-archive-change
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines an automation skill for archiving completed changes in an experimental workflow using 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
|
||||||
|
Defines an automation skill for archiving completed changes in an experimental workflow using 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, Skill tool, openspec-sync-specs skill
|
||||||
|
## arch
|
||||||
|
Skill-based plugin architecture following a declarative markdown skill definition pattern for CLI workflow automation.
|
||||||
|
## tags
|
||||||
|
skill, openspec, defines, archiving, completed, changes, experimental, workflow
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-explore (index)
|
||||||
|
dir: .opencode/skills/openspec-explore
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides a conversational exploration skill for OpenSpec CLI that enables non-implementation ideation, problem investigation, and requirements clarification.
|
||||||
|
## 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
|
||||||
|
Provides a conversational exploration skill for OpenSpec CLI that enables non-implementation ideation, problem investigation, and requirements clarification.
|
||||||
|
## files
|
||||||
|
- SKILL.md | Defines a conversational "explore mode" skill for OpenSpec CLI that acts as a thinking partner for exploring ideas, investigating problems, and clarifying requirements without implementing code. | dep: openspec CLI
|
||||||
|
## arch
|
||||||
|
Skill-based plugin architecture using markdown-defined behavioral specifications with conversational AI patterns, separating exploration/thinking workflows from implementation tasks.
|
||||||
|
## tags
|
||||||
|
skill, defines, conversational, explore, mode, openspec, cli, acts
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-propose (index)
|
||||||
|
dir: .opencode/skills/openspec-propose
|
||||||
|
|
||||||
|
## role
|
||||||
|
Automates the creation of structured software change proposals through a scaffolded directory workflow using 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-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
|
||||||
|
Automates the creation of structured software change proposals through a scaffolded directory workflow using the openspec CLI.
|
||||||
|
## files
|
||||||
|
- SKILL.md | Defines an AI skill that automates proposing new software changes by scaffolding a directory structure and generating dependent artifacts (proposal, design, tasks) using the openspec CLI workflow. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool
|
||||||
|
## arch
|
||||||
|
Template-driven skill architecture following a sequential artifact generation pattern (proposal → design → tasks) with CLI-based workflow automation.
|
||||||
|
## tags
|
||||||
|
skill, defines, automates, proposing, new, software, changes, scaffolding
|
||||||
|
## 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
|
||||||
|
Package .
|
||||||
|
## 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
|
||||||
|
- 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
|
||||||
|
- ui-audit-spacing-typography.md
|
||||||
|
- ui-rework-foundations-apply.md
|
||||||
|
- ui-rework-pass2-apply.md
|
||||||
|
## links
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
map: ./.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# .
|
||||||
|
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
|
||||||
|
Package .
|
||||||
|
## files
|
||||||
|
- .env.example | Provides example environment variable configuration for a full-stack application with database, caching, authentication, and deployment settings
|
||||||
|
- .gitignore | Specifies files and directories for Git to ignore across a project using Beads/Dolt, Python, Node, and various IDE/OS tooling. | dep: git, dolt, beads, python, node, npm, yarn, pnpm, pytest, mypy, ruff, coverage
|
||||||
|
- AGENTS.md | Defines operational guidelines and workflows for AI agents collaborating on a software project governed by OpenSpec | dep: OpenSpec, superpowers (brainstorming, writing-plans, test-driven-development, systematic-debugging, verification-before-completion, using-git-worktrees, dispatching-parallel-agents), git
|
||||||
|
- CHANGELOG.md | Documents version history and notable changes for a project management and Git repository application
|
||||||
|
- Makefile | Provides standardized development commands for managing a Docker-based full-stack application with API, web frontend, database, and testing infrastructure | dep: docker compose, alembic, pytest, ruff, mypy, playwright, npm, postgres, redis
|
||||||
|
- README.md | Documentation for a self-hosted development platform that manages 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
|
||||||
|
- docker-compose.traefik.yml | Deploys a multi-service application (PostgreSQL, Redis, web frontend, API) behind an existing Traefik reverse proxy with TLS termination | dep: docker, docker-compose, traefik, postgres, redis, node/vite, python/fastapi
|
||||||
|
- docker-compose.yml | Defines a multi-service Docker Compose stack for a web application with PostgreSQL, Redis, API backend, and web frontend services | dep: Docker, Docker Compose, PostgreSQL, Redis, Vite, asyncpg, Python/FastAPI (implied), Node.js (implied)
|
||||||
|
- progress.md | Documents the progress and remaining tasks for a backend-frontend refactoring project involving modularization, code reorganization, and verification.
|
||||||
|
- swap-pane | Provides a command to swap the position of two tmux panes within a window or between windows | dep: tmux, client, window, layout, cmd-find, cmd-parse, options
|
||||||
|
- ui-audit-spacing-typography.md | A detailed audit report identifying critical CSS styling gaps, mobile layout failures, and design system inconsistencies in a web application's UI components and stylesheets. | dep: React/TSX components, CSS stylesheets (global.css, utilities.css, tokens.css, page-specific CSS), JSX/TSX files in apps/web/src/components and apps/web/src/pages
|
||||||
|
- ui-rework-foundations-apply.md | Documents the implementation of Pass 1 (Foundations) of a web UI spacing/typography/visual-rhythm rework, including design token expansion, primitive CSS class additions, component refactors, and OpenSpec documentation. | dep: CSS design tokens, React/TSX components, OpenSpec documentation system, Git/SSH, npm build toolchain
|
||||||
|
- ui-rework-pass2-apply.md | Documents the implementation and verification of Pass 2 of a web UI spacing/typography rework, refactoring inline styles into utility classes and design tokens across six components. | dep: CSS custom properties, BEM methodology, utility-first CSS, React/TSX components, OpenSpec documentation system
|
||||||
|
## arch
|
||||||
|
Contains 13 files.
|
||||||
|
## tags
|
||||||
|
docker, web, git, application, ui, rework, python, redis
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# .stoneforge (index)
|
||||||
|
dir: .stoneforge
|
||||||
|
|
||||||
|
## role
|
||||||
|
Infrastructure configuration and metadata storage for the Stoneforge tool
|
||||||
|
## 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
|
||||||
|
Infrastructure configuration and metadata storage for the Stoneforge tool
|
||||||
|
## files
|
||||||
|
- .dashboard-opened | Stores timestamp metadata for tracking when a dashboard was opened
|
||||||
|
- .gitignore | Specifies files and patterns for Git to ignore in the repository | dep: git
|
||||||
|
- config.yaml | YAML configuration file defining settings for the Stoneforge tool, including database, sync, playbooks, identity, merge, workflow, and agent permissions.
|
||||||
|
## arch
|
||||||
|
Dot-directory pattern for tool-specific state, using YAML for hierarchical configuration and dotfiles for ephemeral runtime metadata
|
||||||
|
## tags
|
||||||
|
git, config, stores, timestamp, metadata, tracking, dashboard, was
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# .stoneforge/sync (index)
|
||||||
|
dir: .stoneforge/sync
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides persistent storage for entity relationships and AI worker agent records in a synchronization 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
|
||||||
|
Provides persistent storage for entity relationships and AI worker agent records in a synchronization system.
|
||||||
|
## files
|
||||||
|
- dependencies.jsonl | Stores dependency relationships between entities in JSONL format, including parent-child, blocking, and reply-to relationships with timestamps and creators.
|
||||||
|
- elements.jsonl | Stores JSONL-formatted entity records for AI worker agents with their session histories and metadata in a data persistence file.
|
||||||
|
## arch
|
||||||
|
Append-only JSONL document store with separate files for dependency graph edges (dependencies.jsonl) and entity nodes (elements.jsonl), using a log-structured data pattern for time-ordered, immutable record keeping.
|
||||||
|
## tags
|
||||||
|
stores, relationships, jsonl, dependencies, elements, dependency, entities, format
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -75,6 +75,7 @@ Do not:
|
|||||||
* Introduce new dependencies without clear justification.
|
* Introduce new dependencies without clear justification.
|
||||||
* Treat existing code as more authoritative than OpenSpec for intended behavior.
|
* Treat existing code as more authoritative than OpenSpec for intended behavior.
|
||||||
* Decide product behavior silently when the spec is unclear.
|
* 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.
|
If scope must change, propose an OpenSpec update first.
|
||||||
|
|
||||||
|
|||||||
@@ -1,150 +1,61 @@
|
|||||||
# Headquarter
|
# Headquarter
|
||||||
|
|
||||||
A self-hosted platform for managing projects, git repositories, and development tools with OAuth2 authentication.
|
Headquarter is a self-hosted workspace for managing projects, Git repositories, development-tool instances, SSH keys, and user preferences. It has a FastAPI API, a React frontend, PostgreSQL and Redis, and Authentik OAuth2/session authentication. Built-in tool definitions include code-server and Jupyter.
|
||||||
|
|
||||||
## Overview
|
## Prerequisites
|
||||||
|
|
||||||
Headquarter provides a centralized workspace for development teams to:
|
- Docker and Docker Compose for the provided local and production Compose stacks.
|
||||||
- Manage projects and their associated git repositories
|
- Git for repository workflows.
|
||||||
- Browse repository files and view git history
|
- Python 3.11 or newer for manual API development.
|
||||||
- Spawn development tools (VS Code Server, Jupyter Notebook, etc.)
|
- Node.js and npm for manual frontend development.
|
||||||
- Manage SSH keys and user preferences
|
- An Authentik configuration for the authenticated deployment.
|
||||||
|
|
||||||
## Features
|
Production additionally requires an existing Traefik network and host Docker access for tool-instance management.
|
||||||
|
|
||||||
### Project Management
|
## Local Compose setup
|
||||||
- Create and manage projects
|
|
||||||
- View all projects in a dashboard
|
|
||||||
- Click any project to open its workspace
|
|
||||||
|
|
||||||
### Git Repository Management
|
1. Create a local environment file from the template and replace placeholder credentials before using a shared or production-like environment:
|
||||||
- Initialize bare repositories
|
|
||||||
- Clone repositories (including mirror clones)
|
|
||||||
- Smart URL parsing (converts browser URLs to git URLs)
|
|
||||||
- View repository history and commit details
|
|
||||||
|
|
||||||
### Repository Workspace
|
|
||||||
- Browse files and directories
|
|
||||||
- View file contents with syntax highlighting
|
|
||||||
- Switch between branches
|
|
||||||
- Quick file editing with automatic commits
|
|
||||||
|
|
||||||
### Git History Visualization
|
|
||||||
- View commit history with branch graph
|
|
||||||
- See commit details, statistics, and diffs
|
|
||||||
- Filter by branch
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
- OAuth2 via Authentik
|
|
||||||
- Session-based authentication
|
|
||||||
- User profile management
|
|
||||||
|
|
||||||
### Tool Management
|
|
||||||
- Built-in tool types (code-server, jupyter-notebook)
|
|
||||||
- Create custom tool types with Docker Compose templates
|
|
||||||
- Template validation
|
|
||||||
|
|
||||||
### User Settings
|
|
||||||
- Theme selection (system/light/dark)
|
|
||||||
- Git identity configuration
|
|
||||||
- Default editor preference
|
|
||||||
|
|
||||||
### SSH Key Management
|
|
||||||
- Generate Ed25519 key pairs
|
|
||||||
- Copy public keys to clipboard
|
|
||||||
- Delete keys
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- Docker and Docker Compose
|
|
||||||
- Git
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
|
|
||||||
1. **Clone the repository:**
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd headquarter
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Set up environment:**
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env with your settings
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Start services:**
|
2. Start the local stack:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Access the application:**
|
The local Compose stack starts PostgreSQL, Redis, the API, and the web frontend. It publishes the web frontend at <http://localhost:3000>, the API at <http://localhost:8000>, and API documentation at <http://localhost:8000/docs>. PostgreSQL and Redis are also published on ports 5432 and 6379 respectively.
|
||||||
- Frontend: http://localhost:5173
|
|
||||||
- API: http://localhost:8000
|
|
||||||
- API Docs: http://localhost:8000/docs
|
|
||||||
|
|
||||||
### Production Deployment
|
> **Authentication limitation:** this command does not configure the `AUTHENTIK_*`, `API_DOMAIN`, or `WEB_DOMAIN` values required for a verified authenticated flow. Treat authenticated local use as unsupported until those values are supplied through a documented local configuration.
|
||||||
|
|
||||||
See [Deployment Guide](docs/deployment/) for production setup with Traefik and Authentik.
|
Useful operational commands:
|
||||||
|
|
||||||
## Tech Stack
|
```bash
|
||||||
|
make up # Start services
|
||||||
### Backend
|
make down # Stop services
|
||||||
- **FastAPI** - Python web framework
|
make logs # Follow Compose logs
|
||||||
- **SQLAlchemy** - ORM with async PostgreSQL support
|
make migrate # Apply database migrations in the API container
|
||||||
- **Pydantic** - Data validation
|
make health # Show Compose service status
|
||||||
- **Alembic** - Database migrations
|
|
||||||
- **python-jose** - JWT handling
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- **React** - UI library
|
|
||||||
- **TypeScript** - Type safety
|
|
||||||
- **Vite** - Build tool
|
|
||||||
- **React Router** - Client-side routing
|
|
||||||
|
|
||||||
### Infrastructure
|
|
||||||
- **Docker** - Containerization
|
|
||||||
- **PostgreSQL** - Database
|
|
||||||
- **Traefik** - Reverse proxy (production)
|
|
||||||
- **Authentik** - Identity provider
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [User Guide](docs/features/) - Feature documentation
|
|
||||||
- [API Reference](docs/api/) - API endpoints
|
|
||||||
- [Architecture](docs/architecture/) - System design
|
|
||||||
- [Deployment](docs/deployment/) - Setup guides
|
|
||||||
- [Development](docs/development/) - Contributing
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── apps/
|
|
||||||
│ ├── api/ # FastAPI backend
|
|
||||||
│ │ ├── src/
|
|
||||||
│ │ │ ├── api/ # API routes
|
|
||||||
│ │ │ ├── auth/ # Authentication
|
|
||||||
│ │ │ ├── models/ # Database models
|
|
||||||
│ │ │ └── utils/ # Utilities
|
|
||||||
│ │ ├── tests/ # Test suite
|
|
||||||
│ │ └── Dockerfile
|
|
||||||
│ └── web/ # React frontend
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── api/ # API clients
|
|
||||||
│ │ ├── components/# UI components
|
|
||||||
│ │ └── pages/ # Page components
|
|
||||||
│ └── Dockerfile
|
|
||||||
├── docs/ # Documentation
|
|
||||||
├── docker-compose.yml # Development setup
|
|
||||||
├── docker-compose.traefik.yml # Production setup
|
|
||||||
└── Makefile # Common commands
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Production deployment
|
||||||
|
|
||||||
|
The production Compose file is [`docker-compose.traefik.yml`](docker-compose.traefik.yml). It expects an existing external Traefik network (named `traefik` by default), configured domains, an Authentik client secret, and host paths for repositories, working copies, and tool instances.
|
||||||
|
|
||||||
|
After preparing `.env` with production values, deploy with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.traefik.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The production API container mounts `/var/run/docker.sock` so it can manage tool instances. Treat this as privileged host access and restrict it appropriately. The deployment guide and supporting material are under [`docs/deployment/`](docs/deployment/).
|
||||||
|
|
||||||
|
## Manual development
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
### Backend Development
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/api
|
cd apps/api
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
@@ -153,43 +64,70 @@ pip install -e ".[dev]"
|
|||||||
uvicorn src.main:app --reload
|
uvicorn src.main:app --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Development
|
### Web
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/web
|
cd apps/web
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running Tests
|
## Tests and quality checks
|
||||||
|
|
||||||
|
The Make targets run commands in the Compose services:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend tests
|
|
||||||
make test
|
make test
|
||||||
|
make test-unit
|
||||||
# Frontend tests
|
make test-integration
|
||||||
make test-web
|
make test-system
|
||||||
|
make test-e2e
|
||||||
# All quality gates
|
|
||||||
make lint
|
make lint
|
||||||
make typecheck
|
make typecheck
|
||||||
|
make build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The frontend test command is run directly from its package directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
`make lint` runs API Ruff and mypy checks plus frontend linting; `make typecheck` runs API mypy and frontend typechecking. Neither target runs frontend tests.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Key environment variables:
|
Copy [`.env.example`](.env.example) to `.env` and replace its placeholder values rather than committing credentials. Key settings include:
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Setting | Purpose |
|
||||||
|----------|-------------|---------|
|
| --- | --- |
|
||||||
| `API_DOMAIN` | API domain | `localhost` |
|
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | PostgreSQL connection settings |
|
||||||
| `WEB_DOMAIN` | Web domain | `localhost` |
|
| `REDIS_URL` | Redis connection URL |
|
||||||
| `AUTHENTIK_DOMAIN` | Authentik domain | - |
|
| `SESSION_SECRET`, `SESSION_TTL_HOURS` | Session signing and lifetime |
|
||||||
| `AUTHENTIK_CLIENT_ID` | OAuth client ID | - |
|
| `API_DOMAIN`, `WEB_DOMAIN` | Public API and web domains |
|
||||||
| `AUTHENTIK_CLIENT_SECRET` | OAuth client secret | - |
|
| `AUTHENTIK_DOMAIN`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET` | Authentik OAuth configuration |
|
||||||
| `DATABASE_URL` | PostgreSQL URL | - |
|
| `AUTHENTIK_APPLICATION_SLUG` | Authentik application path component |
|
||||||
| `JWT_SECRET` | JWT signing secret | - |
|
| `VITE_API_BASE_URL`, `VITE_APP_URL` | Frontend build-time public URLs |
|
||||||
| `REPO_BASE_PATH` | Repository storage path | `/data/repos` |
|
| `REPO_BASE_PATH` | Repository storage path |
|
||||||
|
| `TRAEFIK_NETWORK` | Existing Traefik network for the production Compose stack |
|
||||||
|
|
||||||
See [Environment Variables](docs/deployment/environment.md) for complete list.
|
The API reads `.env` settings and has development defaults, but defaults such as local credentials and session secrets are not suitable for production.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── apps/
|
||||||
|
│ ├── api/ # FastAPI API, migrations, and tests
|
||||||
|
│ └── web/ # React/Vite frontend
|
||||||
|
├── docs/ # Architecture, API, feature, deployment, and development docs
|
||||||
|
├── e2e/ # Playwright end-to-end tests
|
||||||
|
├── docker-compose.yml # Local Compose stack
|
||||||
|
├── docker-compose.traefik.yml # Traefik deployment stack
|
||||||
|
└── Makefile # Compose, test, quality, and build commands
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[License information]
|
No license file or declared license was found in this checkout.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# apps (index)
|
||||||
|
dir: apps
|
||||||
|
|
||||||
|
## role
|
||||||
|
Package apps
|
||||||
|
## 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
|
||||||
|
Package apps
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Contains 0 files.
|
||||||
|
## 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/
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# apps/api (index)
|
||||||
|
dir: apps/api
|
||||||
|
|
||||||
|
## role
|
||||||
|
Backend API server for the Headquarter platform, providing self-hosted project management, git repository management, and development tool orchestration services.
|
||||||
|
## parent
|
||||||
|
index: apps/.pi-map.index.md
|
||||||
|
map: apps/.pi-map.md
|
||||||
|
## children
|
||||||
|
- apps/api/.mypy_cache
|
||||||
|
index: apps/api/.mypy_cache/.pi-map.index.md
|
||||||
|
map: apps/api/.mypy_cache/.pi-map.md
|
||||||
|
- 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/.ruff_cache
|
||||||
|
index: apps/api/.ruff_cache/.pi-map.index.md
|
||||||
|
map: apps/api/.ruff_cache/.pi-map.md
|
||||||
|
- apps/api/alembic
|
||||||
|
index: apps/api/alembic/.pi-map.index.md
|
||||||
|
map: apps/api/alembic/.pi-map.md
|
||||||
|
- apps/api/app
|
||||||
|
index: apps/api/app/.pi-map.index.md
|
||||||
|
map: apps/api/app/.pi-map.md
|
||||||
|
- apps/api/headquarter_api.egg-info
|
||||||
|
index: apps/api/headquarter_api.egg-info/.pi-map.index.md
|
||||||
|
map: apps/api/headquarter_api.egg-info/.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
|
||||||
|
Backend API server for the Headquarter platform, providing self-hosted project management, git repository management, and development tool orchestration services.
|
||||||
|
## files
|
||||||
|
- .dockerignore | Specifies files and directories to exclude from the Docker build context to optimize image build times and prevent sensitive or unnecessary files from being included.
|
||||||
|
- Dockerfile | Multi-stage Dockerfile that builds and runs a Python application with Docker CLI access, cloudflared, and database readiness checks. | dep: python:3.11-slim, libpq5, git, openssh-client, docker-ce-cli, docker-compose-plugin, cloudflared, uvicorn, netcat-openbsd
|
||||||
|
- README.md | Provides comprehensive documentation for the Headquarter API, a self-hosted platform for managing projects, git repositories, and development tools. | dep: FastAPI, SQLAlchemy, PostgreSQL, asyncpg, Alembic, Docker, Authentik, Pydantic, Ruff, mypy, pytest
|
||||||
|
- alembic.ini | Configuration file for Alembic database migration tool, defining database connection and logging settings. | dep: alembic, sqlalchemy, asyncpg, postgresql
|
||||||
|
- pyproject.toml | Defines project metadata, dependencies, and tool configuration for the Headquarter platform API. | dep: fastapi, uvicorn, sqlalchemy, asyncpg, alembic, pydantic, pydantic-settings, httpx, structlog, cryptography, pytest, mypy, ruff
|
||||||
|
- uv.lock | This file is a UV lockfile that pins exact versions, hashes, and metadata for all Python project dependencies to ensure reproducible environments. | dep: uv, aiosqlite, alembic, annotated-types, anyio, asyncpg, sqlalchemy, mako
|
||||||
|
- wait-for-db.sh | Polls a PostgreSQL host/port until it is available or a retry limit is reached, then executes the passed command. | dep: nc, sleep
|
||||||
|
## arch
|
||||||
|
Modern Python async API using FastAPI/Starlette with SQLAlchemy ORM, Alembic migrations, multi-stage Docker containerization with cloudflared tunneling, UV package management, and PostgreSQL database with health-checked startup orchestration.
|
||||||
|
## tags
|
||||||
|
alembic, docker, sqlalchemy, asyncpg, dockerfile, database, postgresql, pydantic
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
+2
-2
@@ -50,8 +50,8 @@ ENV PATH=/root/.local/bin:$PATH
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY --chown=appuser:appgroup . .
|
COPY --chown=appuser:appgroup . .
|
||||||
|
|
||||||
# Create directories for repo and instance storage
|
# Create directories for repo, instance, and workspace storage
|
||||||
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
|
RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data
|
||||||
|
|
||||||
# Copy wait-for-db script
|
# Copy wait-for-db script
|
||||||
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
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 tooling for the API service, enabling version-controlled schema changes 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 tooling for the API service, enabling version-controlled schema changes with async SQLAlchemy support.
|
||||||
|
## files
|
||||||
|
- env.py | Configures Alembic database migration environment with async SQLAlchemy support. | 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 Python migration files for SQLAlchemy database schema changes | dep: alembic, sqlalchemy, mako
|
||||||
|
## arch
|
||||||
|
Template-based migration generation using Alembic's standard env.py configuration pattern with async SQLAlchemy engine integration and Mako templating for migration script scaffolding.
|
||||||
|
## 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,78 @@
|
|||||||
|
# apps/api/alembic/versions (index)
|
||||||
|
dir: apps/api/alembic/versions
|
||||||
|
|
||||||
|
## role
|
||||||
|
Database schema versioning and incremental migration management for the API's relational data model using Alembic.
|
||||||
|
## parent
|
||||||
|
index: apps/api/alembic/.pi-map.index.md
|
||||||
|
map: apps/api/alembic/.pi-map.md
|
||||||
|
## children
|
||||||
|
- apps/api/alembic/versions/.ruff_cache
|
||||||
|
index: apps/api/alembic/versions/.ruff_cache/.pi-map.index.md
|
||||||
|
map: apps/api/alembic/versions/.ruff_cache/.pi-map.md
|
||||||
|
## 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
|
||||||
|
- 2026_06_14_104415_add_tool_type_home_directory.py
|
||||||
|
- 2026_06_14_182955_fix_pi_agent_home_directory_mount.py
|
||||||
|
- 2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py
|
||||||
|
- 2026_06_19_113000_remove_pi_agent_workspace_symlink.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
|
||||||
|
- fc8f1a20cbf6_merge_home_directory_and_pi_agent_mount_.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
|
||||||
|
- explore versions subdirectories
|
||||||
|
index: apps/api/alembic/versions/.ruff_cache/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# apps/api/alembic/versions
|
||||||
|
dir: apps/api/alembic/versions
|
||||||
|
|
||||||
|
index: apps/api/alembic/versions/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Database schema versioning and incremental migration management for the API's relational data model using Alembic.
|
||||||
|
## files
|
||||||
|
- 0001_initial_schema.py | Alembic database migration that creates the initial schema with five tables (users, ssh_keys, projects, git_repositories, user_configs) for a Git/SSH management application. | 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, sqlalchemy.dialects.postgresql
|
||||||
|
- 0002_refresh_tokens.py | Alembic database migration that creates a refresh_tokens table with indexes for secure session 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 per user | 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 user tracking fields | 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 foreign key relationships to tool_types, git_repositories, projects, and users tables, plus indexes for common query patterns. | 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 primary keys, foreign keys to users/tool_types/projects, key-value configuration storage, and supporting indexes. | 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, sqlalchemy.dialects.postgresql
|
||||||
|
- 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 default_port values for existing tool types and 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, config_includes, config_mounts tables and a selected_profile_id column to tool_instances with defensive checks for idempotent execution | 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 | Database migration to add 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 to config_profiles and config_mounts tables with idempotent schema 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:_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 without applying schema changes | 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 a single `interface_type` string column and adds a `requires_port` boolean column, with dialect-specific SQL for PostgreSQL and SQLite data migration. | 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, sqlalchemy.dialects.postgresql
|
||||||
|
- 069d3da4dc9b_add_ssh_key_id_to_config_profiles.py | Alembic database migration that adds an ssh_key_id foreign key column to the config_profiles table | 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 | Adds a database migration that inserts a new "pi-agent" tool type into a tool_types table, defining a terminal-based Docker development environment with nvim, ranger, and tmux. | 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 that removes the `is_builtin` column from the `tool_types` table with a downgrade path to restore it. | 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 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 selected_config_profile_id foreign key to tool_instances. | 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, sqlalchemy.dialects.postgresql
|
||||||
|
- 2026_05_26_add_git_mounts.py | Database migration to add 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 VARCHAR(64). | 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 | Creates two database tables (instance_events and health_checks) for monitoring tool instances via an Alembic migration. | 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 foreign key to tool_instances, including indexes and audit timestamps | 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 support to tool_types and tool_instances, and migrates the pi-agent tool to a manifest-based definition with a base Ubuntu image. | 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 the tool_configs and config_folders tables with conditional existence checks, and provides downgrade to recreate them | 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 | Creates a notifications table with user-linked, categorized, severity-graded messages supporting read/dismissed states and optimized querying via partial 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 ssh_key_ids column 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 by replacing incorrect `--host` flags with proper `--bind-addr 0.0.0.0:PORT` configurations in both database-stored tool type templates and on-disk instance compose files. | 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 fixes web tool (code-server and jupyter-notebook) compose templates to bind to 0.0.0.0 for network accessibility. | 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 from LinuxServer.io (LSIO) 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
|
||||||
|
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py | Alembic database migration that removes the ssh_keys mount from the pi-agent manifest in the tool_definition_manifests table, with downgrade support to restore it. | 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 the `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
|
||||||
|
- 2026_06_14_104415_add_tool_type_home_directory.py | An Alembic database migration that adds a `home_directory` column to `tool_types` table and updates template strings from `/workspace` to a configurable `/home/user/{{WORKSPACE_NAME}}` path. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:op.execute, call:sa.update(tool_types) .where(tool_types.c.compose_template.is_not(None)) .values, call:tool_types.c.compose_template.is_not, call:sa.func.replace, call:sa.update(tool_types) .where(tool_types.c.dockerfile_template.is_not(None)) .values, call:tool_types.c.dockerfile_template.is_not, func:downgrade() → None, call:op.execute, call:sa.update(tool_types) .where(tool_types.c.compose_template.is_not(None)) .values, call:tool_types.c.compose_template.is_not, call:sa.func.replace, call:sa.update(tool_types) .where(tool_types.c.dockerfile_template.is_not(None)) .values, call:tool_types.c.dockerfile_template.is_not, call:op.drop_column | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
|
||||||
|
- 2026_06_14_182955_fix_pi_agent_home_directory_mount.py | Alembic database migration that fixes the pi-agent tool definition manifest's home directory mount path from /workspace to ~/{{WORKSPACE_NAME}} with a compatibility symlink and updated startup script. | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[Union[str, None], Union[dict, None]], call:conn.execute( sa.select(tool_definition_manifests.c.id, tool_definition_manifests.c.manifest) .where(tool_definition_manifests.c.name == "pi-agent") ).fetchone, call:sa.select(tool_definition_manifests.c.id, tool_definition_manifests.c.manifest) .where, call:dict, func:_update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) → None, call:conn.execute, call:sa.update(tool_definition_manifests) .where(tool_definition_manifests.c.id == manifest_id) .values, func:upgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:manifest.setdefault, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:manifest.setdefault, call:_update_manifest | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
|
||||||
|
- 2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py | Alembic database migration that removes explicit repo mounts from the pi-agent tool definition manifest and restores them on downgrade | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[Union[str, None], Union[dict, None]], call:conn.execute( sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where(tool_definition_manifests.c.name == "pi-agent") ).fetchone, call:sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where, call:dict, func:_update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) → None, call:conn.execute, call:sa.update(tool_definition_manifests) .where(tool_definition_manifests.c.id == manifest_id) .values, func:upgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.get, call:mount.get, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.setdefault, call:any, call:mount.get, call:mounts.append, call:_update_manifest | dep: typing, alembic, sqlalchemy.sql, sqlalchemy
|
||||||
|
- 2026_06_19_113000_remove_pi_agent_workspace_symlink.py | Alembic database migration that updates the pi-agent tool definition manifest to remove the /workspace symlink dependency and adjust working directory and startup script accordingly. | exp: func:_find_pi_agent_manifest(conn: sa.Connection) → tuple[str | None, dict | None], call:conn.execute( sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where(tool_definition_manifests.c.name == "pi-agent") ).fetchone, call:sa.select( tool_definition_manifests.c.id, tool_definition_manifests.c.manifest ).where, call:dict, func:_update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) → None, call:conn.execute, call:sa.update(tool_definition_manifests) .where(tool_definition_manifests.c.id == manifest_id) .values, func:upgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.setdefault, call:runtime.get, call:_update_manifest, func:downgrade() → None, call:op.get_bind, call:_find_pi_agent_manifest, call:manifest.setdefault, call:_update_manifest | dep: collections.abc, alembic, sqlalchemy.sql, sqlalchemy
|
||||||
|
- 398082499c30_add_tool_config_fields.py | Alembic database migration that adds configuration fields (port_override, start_command, working_directory, environment_variables, volumes) to the tool_configs table with a port range validation 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 merge migration that combines two parallel migration branches (remove_is_builtin and add_config_profiles) into a single revision history | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
|
||||||
|
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py | Merges two Alembic migration branches (profile resolver and workspaces) into a single migration head | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
|
||||||
|
- 8c6d1dbd4798_remove_pi_config_and_state_mounts_from_.py | Alembic database migration that removes or restores pi_state and pi_config mounts from a JSON manifest stored in the tool_definition_manifests table for the 'pi-agent' tool. | 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 UUID primary key, user foreign key, JSONB fields for files and project overrides, and supporting indexes/constraints | 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, postgresql dialect
|
||||||
|
- af8512103d67_add_tool_type_fields.py | Alembic database migration that adds columns (definition_type, dockerfile_template, build_context, readiness_probe) and a check constraint to the tool_types table. | 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 previous migrations (single_interface and clone_mode) into a single revision point | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
|
||||||
|
- fc8f1a20cbf6_merge_home_directory_and_pi_agent_mount_.py | Alembic database migration that merges two branch heads (home directory and pi agent mount cleanup) into a single revision point | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
|
||||||
|
## arch
|
||||||
|
Linear migration history with occasional branch/merge patterns (using merge heads), sequential numbered and timestamped revision files, each containing declarative schema changes (CREATE TABLE/ALTER TABLE/DROP TABLE) with idempotent guards, downgrade paths, and occasional data migrations; supports PostgreSQL and SQLite dialects.
|
||||||
|
## tags
|
||||||
|
column, table, call:op.drop, downgrade, upgrade, alembic, 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
|
||||||
|
- explore versions subdirectories
|
||||||
|
index: apps/api/alembic/versions/.ruff_cache/.pi-map.index.md
|
||||||
|
## 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")
|
||||||
@@ -104,11 +104,11 @@ RUN git config --global init.defaultBranch main \\
|
|||||||
&& git config --global user.name "Developer"
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
# Create default tmux config
|
# Create default tmux config
|
||||||
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
RUN printf '%s\\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||||
|
|
||||||
# Create default ranger config
|
# Create default ranger config
|
||||||
RUN mkdir -p /home/user/.config/ranger \\
|
RUN mkdir -p /home/user/.config/ranger \\
|
||||||
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
&& printf '%s\\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||||
|
|
||||||
# Set up Pi config directory
|
# Set up Pi config directory
|
||||||
RUN mkdir -p /home/user/.pi/agent
|
RUN mkdir -p /home/user/.pi/agent
|
||||||
|
|||||||
@@ -232,20 +232,6 @@ def upgrade() -> None:
|
|||||||
"writable": True,
|
"writable": True,
|
||||||
"owner": "user",
|
"owner": "user",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"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": {
|
"runtime": {
|
||||||
"command": ["/bin/bash"],
|
"command": ["/bin/bash"],
|
||||||
@@ -322,10 +308,10 @@ RUN git config --global init.defaultBranch main \\
|
|||||||
&& git config --global user.email "dev@headquarter.local" \\
|
&& git config --global user.email "dev@headquarter.local" \\
|
||||||
&& git config --global user.name "Developer"
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
RUN printf '%s\\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||||
|
|
||||||
RUN mkdir -p /home/user/.config/ranger \\
|
RUN mkdir -p /home/user/.config/ranger \\
|
||||||
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
&& printf '%s\\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||||
|
|
||||||
RUN mkdir -p /home/user/.pi/agent
|
RUN mkdir -p /home/user/.pi/agent
|
||||||
|
|
||||||
|
|||||||
@@ -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,89 @@
|
|||||||
|
"""add_tool_type_home_directory
|
||||||
|
|
||||||
|
Revision ID: 2026_06_14_104415
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-06-14 10:44:15.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.sql import column, table
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_06_14_104415"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
tool_types = table(
|
||||||
|
"tool_types",
|
||||||
|
column("id", sa.UUID),
|
||||||
|
column("home_directory", sa.String),
|
||||||
|
column("compose_template", sa.Text),
|
||||||
|
column("dockerfile_template", sa.Text),
|
||||||
|
)
|
||||||
|
|
||||||
|
OLD_WORKSPACE = "/workspace"
|
||||||
|
NEW_WORKSPACE = "/home/user/{{WORKSPACE_NAME}}"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add the new column with a default that applies to existing rows.
|
||||||
|
op.add_column(
|
||||||
|
"tool_types",
|
||||||
|
sa.Column(
|
||||||
|
"home_directory",
|
||||||
|
sa.String(255),
|
||||||
|
nullable=False,
|
||||||
|
server_default="/home/user",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rewrite legacy templates that mount the workspace at /workspace so they
|
||||||
|
# use the new configurable home directory and preserve the workspace name.
|
||||||
|
op.execute(
|
||||||
|
sa.update(tool_types)
|
||||||
|
.where(tool_types.c.compose_template.is_not(None))
|
||||||
|
.values(
|
||||||
|
compose_template=sa.func.replace(
|
||||||
|
tool_types.c.compose_template, OLD_WORKSPACE, NEW_WORKSPACE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.update(tool_types)
|
||||||
|
.where(tool_types.c.dockerfile_template.is_not(None))
|
||||||
|
.values(
|
||||||
|
dockerfile_template=sa.func.replace(
|
||||||
|
tool_types.c.dockerfile_template, OLD_WORKSPACE, NEW_WORKSPACE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Restore the original /workspace strings before dropping the column.
|
||||||
|
op.execute(
|
||||||
|
sa.update(tool_types)
|
||||||
|
.where(tool_types.c.compose_template.is_not(None))
|
||||||
|
.values(
|
||||||
|
compose_template=sa.func.replace(
|
||||||
|
tool_types.c.compose_template, NEW_WORKSPACE, OLD_WORKSPACE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.update(tool_types)
|
||||||
|
.where(tool_types.c.dockerfile_template.is_not(None))
|
||||||
|
.values(
|
||||||
|
dockerfile_template=sa.func.replace(
|
||||||
|
tool_types.c.dockerfile_template, NEW_WORKSPACE, OLD_WORKSPACE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_column("tool_types", "home_directory")
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""fix_pi_agent_home_directory_mount
|
||||||
|
|
||||||
|
Revision ID: 2026_06_14_182955
|
||||||
|
Revises: fc8f1a20cbf6
|
||||||
|
Create Date: 2026-06-14 18:29:55.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.sql import column, table
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_06_14_182955"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "fc8f1a20cbf6"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
tool_definition_manifests = table(
|
||||||
|
"tool_definition_manifests",
|
||||||
|
column("id", sa.UUID),
|
||||||
|
column("name", sa.String),
|
||||||
|
column("manifest", sa.JSON),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_pi_agent_manifest(conn: sa.Connection) -> tuple[Union[str, None], Union[dict, None]]:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.select(tool_definition_manifests.c.id, tool_definition_manifests.c.manifest)
|
||||||
|
.where(tool_definition_manifests.c.name == "pi-agent")
|
||||||
|
).fetchone()
|
||||||
|
if result is None:
|
||||||
|
return None, None
|
||||||
|
return result.id, dict(result.manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) -> None:
|
||||||
|
conn.execute(
|
||||||
|
sa.update(tool_definition_manifests)
|
||||||
|
.where(tool_definition_manifests.c.id == manifest_id)
|
||||||
|
.values(manifest=manifest)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
manifest_id, manifest = _find_pi_agent_manifest(conn)
|
||||||
|
if not manifest_id or not manifest:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Mount the repo under the configured home directory, preserving the repo
|
||||||
|
# directory name via the WORKSPACE_NAME runtime variable.
|
||||||
|
for mount in manifest.get("mounts", []):
|
||||||
|
if mount.get("source_type") == "repo":
|
||||||
|
mount["target"] = "~/{{WORKSPACE_NAME}}"
|
||||||
|
|
||||||
|
# Keep /workspace as a compatibility symlink to the real mount path.
|
||||||
|
runtime = manifest.setdefault("runtime", {})
|
||||||
|
runtime["working_dir"] = "/workspace"
|
||||||
|
|
||||||
|
# Update the startup script to chown the real mount path.
|
||||||
|
scripts = manifest.setdefault("scripts", {})
|
||||||
|
scripts["startup"] = [
|
||||||
|
'if [ -n "$WORKSPACE_NAME" ]; then sudo chown -R user:user "$HOME/$WORKSPACE_NAME" 2>/dev/null || true; fi',
|
||||||
|
]
|
||||||
|
|
||||||
|
_update_manifest(conn, manifest_id, manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
manifest_id, manifest = _find_pi_agent_manifest(conn)
|
||||||
|
if not manifest_id or not manifest:
|
||||||
|
return
|
||||||
|
|
||||||
|
for mount in manifest.get("mounts", []):
|
||||||
|
if mount.get("source_type") == "repo":
|
||||||
|
mount["target"] = "/workspace"
|
||||||
|
|
||||||
|
runtime = manifest.setdefault("runtime", {})
|
||||||
|
runtime["working_dir"] = "/workspace"
|
||||||
|
|
||||||
|
scripts = manifest.setdefault("scripts", {})
|
||||||
|
scripts["startup"] = [
|
||||||
|
"if [ -d /workspace ]; then sudo chown -R user:user /workspace 2>/dev/null || true; fi",
|
||||||
|
]
|
||||||
|
|
||||||
|
_update_manifest(conn, manifest_id, manifest)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""remove pi agent explicit repo mount
|
||||||
|
|
||||||
|
Revision ID: 2026_06_15_090500
|
||||||
|
Revises: 2026_06_14_182955
|
||||||
|
Create Date: 2026-06-15 09:05:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.sql import column, table
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_06_15_090500"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "2026_06_14_182955"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
tool_definition_manifests = table(
|
||||||
|
"tool_definition_manifests",
|
||||||
|
column("id", sa.UUID),
|
||||||
|
column("name", sa.String),
|
||||||
|
column("manifest", sa.JSON),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_pi_agent_manifest(
|
||||||
|
conn: sa.Connection,
|
||||||
|
) -> tuple[Union[str, None], Union[dict, None]]:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.select(
|
||||||
|
tool_definition_manifests.c.id, tool_definition_manifests.c.manifest
|
||||||
|
).where(tool_definition_manifests.c.name == "pi-agent")
|
||||||
|
).fetchone()
|
||||||
|
if result is None:
|
||||||
|
return None, None
|
||||||
|
return result.id, dict(result.manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) -> None:
|
||||||
|
conn.execute(
|
||||||
|
sa.update(tool_definition_manifests)
|
||||||
|
.where(tool_definition_manifests.c.id == manifest_id)
|
||||||
|
.values(manifest=manifest)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
manifest_id, manifest = _find_pi_agent_manifest(conn)
|
||||||
|
if not manifest_id or not manifest:
|
||||||
|
return
|
||||||
|
|
||||||
|
# The repo mount is now synthesized by compile_compose based on the
|
||||||
|
# instance's repository, so the manifest no longer needs an explicit
|
||||||
|
# repo mount with a {{WORKSPACE_NAME}} placeholder.
|
||||||
|
manifest["mounts"] = [
|
||||||
|
mount
|
||||||
|
for mount in manifest.get("mounts", [])
|
||||||
|
if mount.get("source_type") != "repo"
|
||||||
|
]
|
||||||
|
|
||||||
|
_update_manifest(conn, manifest_id, manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
manifest_id, manifest = _find_pi_agent_manifest(conn)
|
||||||
|
if not manifest_id or not manifest:
|
||||||
|
return
|
||||||
|
|
||||||
|
mounts = manifest.setdefault("mounts", [])
|
||||||
|
if not any(mount.get("source_type") == "repo" for mount in mounts):
|
||||||
|
mounts.append(
|
||||||
|
{
|
||||||
|
"name": "workspace",
|
||||||
|
"target": "~/{{WORKSPACE_NAME}}",
|
||||||
|
"source_type": "repo",
|
||||||
|
"writable": True,
|
||||||
|
"owner": "user",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_update_manifest(conn, manifest_id, manifest)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""remove pi agent workspace symlink
|
||||||
|
|
||||||
|
Revision ID: 2026_06_19_113000
|
||||||
|
Revises: 2026_06_15_090500
|
||||||
|
Create Date: 2026-06-19 11:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.sql import column, table
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_06_19_113000"
|
||||||
|
down_revision: str | Sequence[str] | None = "2026_06_15_090500"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
tool_definition_manifests = table(
|
||||||
|
"tool_definition_manifests",
|
||||||
|
column("id", sa.UUID),
|
||||||
|
column("name", sa.String),
|
||||||
|
column("manifest", sa.JSON),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_pi_agent_manifest(
|
||||||
|
conn: sa.Connection,
|
||||||
|
) -> tuple[str | None, dict | None]:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.select(
|
||||||
|
tool_definition_manifests.c.id, tool_definition_manifests.c.manifest
|
||||||
|
).where(tool_definition_manifests.c.name == "pi-agent")
|
||||||
|
).fetchone()
|
||||||
|
if result is None:
|
||||||
|
return None, None
|
||||||
|
return result.id, dict(result.manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_manifest(conn: sa.Connection, manifest_id: str, manifest: dict) -> None:
|
||||||
|
conn.execute(
|
||||||
|
sa.update(tool_definition_manifests)
|
||||||
|
.where(tool_definition_manifests.c.id == manifest_id)
|
||||||
|
.values(manifest=manifest)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
manifest_id, manifest = _find_pi_agent_manifest(conn)
|
||||||
|
if not manifest_id or not manifest:
|
||||||
|
return
|
||||||
|
|
||||||
|
runtime = manifest.setdefault("runtime", {})
|
||||||
|
# The /workspace compatibility symlink is no longer created by the
|
||||||
|
# compiler/entrypoint. Leaving working_dir set to /workspace causes the
|
||||||
|
# container to start in an empty directory. Let compile_compose default
|
||||||
|
# to /home/user/{workspace_name} instead.
|
||||||
|
if runtime.get("working_dir") == "/workspace":
|
||||||
|
del runtime["working_dir"]
|
||||||
|
|
||||||
|
scripts = manifest.setdefault("scripts", {})
|
||||||
|
# Update the startup script to operate on the real repo-named directory.
|
||||||
|
scripts["startup"] = [
|
||||||
|
'if [ -n "$WORKSPACE_NAME" ]; then sudo chown -R user:user "$HOME/$WORKSPACE_NAME" 2>/dev/null || true; fi',
|
||||||
|
]
|
||||||
|
|
||||||
|
_update_manifest(conn, manifest_id, manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
manifest_id, manifest = _find_pi_agent_manifest(conn)
|
||||||
|
if not manifest_id or not manifest:
|
||||||
|
return
|
||||||
|
|
||||||
|
runtime = manifest.setdefault("runtime", {})
|
||||||
|
runtime["working_dir"] = "/workspace"
|
||||||
|
|
||||||
|
scripts = manifest.setdefault("scripts", {})
|
||||||
|
scripts["startup"] = [
|
||||||
|
"if [ -d /workspace ]; then sudo chown -R user:user /workspace 2>/dev/null || true; fi",
|
||||||
|
]
|
||||||
|
|
||||||
|
_update_manifest(conn, manifest_id, manifest)
|
||||||
@@ -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,23 @@
|
|||||||
|
"""merge home directory and pi agent mount cleanup heads
|
||||||
|
|
||||||
|
Revision ID: fc8f1a20cbf6
|
||||||
|
Revises: 2026_06_14_104415, 8c6d1dbd4798
|
||||||
|
Create Date: 2026-06-14 11:08:41.273502
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'fc8f1a20cbf6'
|
||||||
|
down_revision = ('2026_06_14_104415', '8c6d1dbd4798')
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# apps/api/src (index)
|
||||||
|
dir: apps/api/src
|
||||||
|
|
||||||
|
## role
|
||||||
|
Core FastAPI application package that initializes and configures the Headquarter API 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/.ruff_cache
|
||||||
|
index: apps/api/src/.ruff_cache/.pi-map.index.md
|
||||||
|
map: apps/api/src/.ruff_cache/.pi-map.md
|
||||||
|
- 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/.ruff_cache/.pi-map.index.md, apps/api/src/api/.pi-map.index.md, apps/api/src/auth/.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 FastAPI application package that initializes and configures the Headquarter API 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 using pydantic-settings, including database connectivity, Authentik SSO, JWT, session, and domain-based URL resolution. | exp: class:Settings, func:build_database_url(user: str, password: str, host: str, port: int, database: str) → str | dep: pydantic, pydantic_settings
|
||||||
|
- database.py | Configures an async SQLAlchemy database engine/session and provides a retry-based initialization function that runs Alembic migrations 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 and provides ASGI middleware for logging HTTP requests, responses, and unhandled exceptions. | 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, starlette
|
||||||
|
- main.py | Initializes and configures the FastAPI application, setting up middleware, routers, database connections, and lifecycle event handlers for the Headquarter API. | 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, src.seeds, src.services
|
||||||
|
## arch
|
||||||
|
Layered architecture using Pydantic-settings for configuration, async SQLAlchemy with Alembic migrations, structured JSON logging with correlation ID tracking, and FastAPI lifecycle management with dependency injection for cross-cutting concerns.
|
||||||
|
## tags
|
||||||
|
src, logging, database, call:logger.info, api, middleware, filter, call:logging.get
|
||||||
|
## 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/.ruff_cache/.pi-map.index.md, apps/api/src/api/.pi-map.index.md, apps/api/src/auth/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# apps/api/src/api (index)
|
||||||
|
dir: apps/api/src/api
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines the core API router package with reusable Pydantic validation utilities for container and filesystem-related API schemas.
|
||||||
|
## parent
|
||||||
|
index: apps/api/src/.pi-map.index.md
|
||||||
|
map: apps/api/src/.pi-map.md
|
||||||
|
## children
|
||||||
|
- apps/api/src/api/.ruff_cache
|
||||||
|
index: apps/api/src/api/.ruff_cache/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/.ruff_cache/.pi-map.md
|
||||||
|
- 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/.ruff_cache/.pi-map.index.md, apps/api/src/api/config/.pi-map.index.md, apps/api/src/api/project/.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 the core API router package with reusable Pydantic validation utilities for container and filesystem-related API schemas.
|
||||||
|
## files
|
||||||
|
- __init__.py | Marks the directory as a Python package for API routers.
|
||||||
|
- shared_validators.py | Provides reusable Pydantic validator functions for validating mount paths, file contents, environment variables, and volume mounts in API schemas. | 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 package structure separating router organization from shared cross-cutting validation concerns using Pydantic validators.
|
||||||
|
## 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/.ruff_cache/.pi-map.index.md, apps/api/src/api/config/.pi-map.index.md, apps/api/src/api/project/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -1,6 +1 @@
|
|||||||
from src.api.auth import router as auth_router
|
"""API routers package."""
|
||||||
from src.api.events import router as events_router
|
|
||||||
from src.api.notifications import router as notifications_router
|
|
||||||
from src.api.users import router as users_router
|
|
||||||
|
|
||||||
__all__ = ["auth_router", "events_router", "notifications_router", "users_router"]
|
|
||||||
|
|||||||
@@ -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 config 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 config profiles.
|
||||||
|
## files
|
||||||
|
- __init__.py | Aggregates and exports config-related API routers for module-level access | dep: src.api.config.config_profiles, src.api.config.user_config
|
||||||
|
- config_profiles.py | FastAPI router providing CRUD endpoints for user-owned config profiles with include resolution, default profile management, 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 for retrieving and updating user configuration settings with automatic record creation. | 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 router-based architecture using FastAPI with separate route modules for distinct config domains (profiles vs. user settings), CRUD patterns, and include resolution for hierarchical config.
|
||||||
|
## tags
|
||||||
|
config, profile, get, user, call:uuid.uuid, includes, 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,422 @@
|
|||||||
|
"""Config profile API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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.config import Settings
|
||||||
|
from src.models import ConfigProfile, ConfigProfileInclude, ToolInstance, UserConfig
|
||||||
|
from src.schemas.config import (
|
||||||
|
ConfigProfileCreate,
|
||||||
|
ConfigProfileIncludeUpdate,
|
||||||
|
ConfigProfileResponse,
|
||||||
|
ConfigProfileUpdate,
|
||||||
|
DefaultProfilesUpdate,
|
||||||
|
ValidateGitUrlRequest,
|
||||||
|
ValidateGitUrlResponse,
|
||||||
|
)
|
||||||
|
from src.services.config.config_profile_resolver import (
|
||||||
|
ConfigProfileCycleError,
|
||||||
|
apply_resolved_profile,
|
||||||
|
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"])
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_profile_response(profile: ConfigProfile) -> dict:
|
||||||
|
"""Return profile data with edits from its shared working copy."""
|
||||||
|
response = profile_to_response(profile)
|
||||||
|
root = Path(Settings().instance_base_path) / "config-profiles" / str(profile.id)
|
||||||
|
|
||||||
|
def read_file(path: Path, fallback: str) -> str:
|
||||||
|
try:
|
||||||
|
return path.read_text() if path.is_file() else fallback
|
||||||
|
except OSError:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
response["files"] = {
|
||||||
|
relative_path: read_file(root / "files" / relative_path, content)
|
||||||
|
for relative_path, content in response["files"].items()
|
||||||
|
}
|
||||||
|
response["mounts"] = [dict(mount) for mount in response["mounts"]]
|
||||||
|
for mount in response["mounts"]:
|
||||||
|
mount_root = root / "mounts" / mount["target"].lstrip("/").replace("/", "_")
|
||||||
|
mount["files"] = {
|
||||||
|
relative_path: read_file(mount_root / relative_path, content)
|
||||||
|
for relative_path, content in mount.get("files", {}).items()
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def _running_profile_outcomes(
|
||||||
|
session: AsyncSession, profile_id: uuid.UUID
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Report running instances that must restart to adopt a profile revision."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance).where(ToolInstance.status == "running")
|
||||||
|
)
|
||||||
|
outcomes: list[dict[str, str]] = []
|
||||||
|
for instance in result.scalars().all():
|
||||||
|
if instance.selected_config_profile_id is None:
|
||||||
|
continue
|
||||||
|
resolved = await resolve_profile(session, instance.selected_config_profile_id)
|
||||||
|
dependencies = {resolved.profile_id} | {
|
||||||
|
uuid.UUID(item["id"])
|
||||||
|
for item in resolved.included_profiles
|
||||||
|
if item.get("id")
|
||||||
|
}
|
||||||
|
if profile_id in dependencies:
|
||||||
|
outcomes.append(
|
||||||
|
{
|
||||||
|
"instance_id": str(instance.id),
|
||||||
|
"status": "restart_required",
|
||||||
|
"reason": "Existing instance must restart to adopt shared profile mounts",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return outcomes
|
||||||
|
|
||||||
|
|
||||||
|
@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 [_canonical_profile_response(profile) for profile 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 _canonical_profile_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)
|
||||||
|
resolved = await resolve_profile(session, profile.id)
|
||||||
|
apply_resolved_profile(
|
||||||
|
os.path.join(Settings().instance_base_path, "profile-refresh"),
|
||||||
|
resolved,
|
||||||
|
)
|
||||||
|
response = _canonical_profile_response(profile)
|
||||||
|
response["refresh_outcomes"] = await _running_profile_outcomes(session, profile.id)
|
||||||
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{profile_id}/refresh-git-mounts")
|
||||||
|
async def refresh_profile_git_mounts(
|
||||||
|
profile_id: str,
|
||||||
|
confirm_destructive_refresh: bool = False,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Destructively refresh profile Git working copies used by instances."""
|
||||||
|
if not confirm_destructive_refresh:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Confirm destructive Git refresh before replacing local edits",
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import lazily: instance_service imports tool schemas that transitively
|
||||||
|
# load API routers, so importing it during router initialization cycles.
|
||||||
|
from src.services.tool.instance_service import resolve_git_mounts
|
||||||
|
|
||||||
|
outcomes = await _running_profile_outcomes(session, profile.id)
|
||||||
|
for outcome in outcomes:
|
||||||
|
instance = await session.get(ToolInstance, uuid.UUID(outcome["instance_id"]))
|
||||||
|
if (
|
||||||
|
instance is None
|
||||||
|
or not instance.compose_path
|
||||||
|
or instance.selected_config_profile_id is None
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
resolved = await resolve_profile(session, instance.selected_config_profile_id)
|
||||||
|
await resolve_git_mounts(
|
||||||
|
session,
|
||||||
|
resolved,
|
||||||
|
os.path.dirname(instance.compose_path),
|
||||||
|
)
|
||||||
|
outcome["status"] = "refreshed"
|
||||||
|
outcome["reason"] = "Canonical Git mount source refreshed in place"
|
||||||
|
return {"refresh_outcomes": outcomes}
|
||||||
|
|
||||||
|
|
||||||
|
@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"
|
||||||
|
)
|
||||||
|
|
||||||
|
outcomes = await _running_profile_outcomes(session, profile.id)
|
||||||
|
if outcomes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": "Profile is still used by running instances",
|
||||||
|
"outcomes": outcomes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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,12 +2,12 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -38,28 +38,6 @@ async def _get_or_create_config(
|
|||||||
return config
|
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
|
|
||||||
notification_mute_categories: list[str] | None = None
|
|
||||||
notification_toast_level: 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
|
|
||||||
notification_mute_categories: list[str] | None = None
|
|
||||||
notification_toast_level: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/config",
|
"/config",
|
||||||
response_model=UserConfigResponse,
|
response_model=UserConfigResponse,
|
||||||
File diff suppressed because it is too large
Load Diff
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, exposing CRUD operations and version control functionality to API consumers.
|
||||||
|
## 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
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.auth.dependencies import (
|
||||||
from src.models.git_repository import GitRepository
|
_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.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"])
|
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(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=ProjectResponse,
|
response_model=ProjectResponse,
|
||||||
@@ -76,26 +63,77 @@ async def create_project(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"",
|
"",
|
||||||
response_model=list[ProjectResponse],
|
|
||||||
summary="List all projects",
|
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(
|
async def list_projects(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[Project]:
|
) -> list[dict]:
|
||||||
"""List all projects for the authenticated user.
|
"""List all projects for the authenticated user.
|
||||||
|
|
||||||
Args:
|
Returns projects with nested repositories and workspaces for inline display.
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of projects owned by the user.
|
|
||||||
"""
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
result = await session.execute(
|
||||||
return list(result.scalars().all())
|
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(
|
@router.get(
|
||||||
@@ -184,7 +222,9 @@ async def delete_project(
|
|||||||
project = await _get_owned_project(project_id, user_id, session)
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
# Delete repositories from disk and database
|
# 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()
|
repositories = result.scalars().all()
|
||||||
for repo in repositories:
|
for repo in repositories:
|
||||||
if os.path.exists(repo.path):
|
if os.path.exists(repo.path):
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# apps/api/src/api/system (index)
|
||||||
|
dir: apps/api/src/api/system
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides system-level API endpoints for monitoring, administration, and infrastructure operations including dashboards, health checks, event streaming, instance proxying, notifications, and terminal access.
|
||||||
|
## parent
|
||||||
|
index: apps/api/src/api/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/.pi-map.md
|
||||||
|
## children
|
||||||
|
- apps/api/src/api/system/.ruff_cache
|
||||||
|
index: apps/api/src/api/system/.ruff_cache/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/system/.ruff_cache/.pi-map.md
|
||||||
|
## 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
|
||||||
|
- explore system subdirectories
|
||||||
|
index: apps/api/src/api/system/.ruff_cache/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 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 operations including dashboards, health checks, event streaming, instance proxying, notifications, and terminal access.
|
||||||
|
## files
|
||||||
|
- __init__.py | Aggregates and exports system API routers for a modular web application framework. | 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 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
|
||||||
|
- events.py | Implements an SSE streaming endpoint that delivers instance events to authenticated users with per-user connection limits and heartbeat pings. | dep: asyncio, contextlib, json, uuid, collections.abc, fastapi, src.auth.dependencies, src.services.instance.event_bus
|
||||||
|
- health.py | Provides FastAPI health check endpoints that monitor system health including database connectivity/response time and disk usage, returning structured health status responses. | 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 authenticated users to running containerized tool instances after verifying ownership and instance 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 user notification management including listing, marking as read, dismissing, and retrieving unread counts with support for muted categories. | 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 endpoints for browser-based terminal access to running Docker container tool instances, handling authentication, session management, input/output streaming, and terminal resize/reset operations. | 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:_resolve_container_user(db_session: AsyncSession, instance: ToolInstance) → str | None, call:db_session.get, call:dict, call:resolve_base, call:deep_merge, call:get_manifest_container_user, 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:_resolve_container_user, 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:_resolve_container_user, 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:_resolve_container_user, 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:_resolve_container_user, 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.build.manifest_compiler, src.services.terminal.terminal_manager, src.services.docker, src.auth.session, src.config, starlette
|
||||||
|
## arch
|
||||||
|
Modular FastAPI router pattern with per-feature separation, combining standard REST endpoints, SSE streaming, and WebSocket connections, all with unified authentication and user-scoped access control.
|
||||||
|
## tags
|
||||||
|
terminal, session, call:terminal, call:, src, get, response, instance
|
||||||
|
## symbols
|
||||||
|
- NotificationItem
|
||||||
|
- NotificationListResponse
|
||||||
|
- UnreadCountResponse
|
||||||
|
- MarkAllReadResponse
|
||||||
|
- ClearAllResponse
|
||||||
|
- SessionRef
|
||||||
|
- get_dashboard_summary
|
||||||
|
- health_check
|
||||||
|
## workflows
|
||||||
|
- change system behavior
|
||||||
|
read: __init__.py, dashboard.py, events.py
|
||||||
|
- explore system subdirectories
|
||||||
|
index: apps/api/src/api/system/.ruff_cache/.pi-map.index.md
|
||||||
|
## 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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
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.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
@@ -10,13 +10,13 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id
|
from src.auth.dependencies import get_current_user_id
|
||||||
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
|
|
||||||
router = APIRouter(prefix="/events", tags=["events"])
|
router = APIRouter(prefix="/events", tags=["events"])
|
||||||
|
|
||||||
# In-memory connection counter per user (single-process assumption)
|
# In-memory connection counter per user (single-process assumption)
|
||||||
_connection_counts: dict[uuid.UUID, int] = {}
|
_connection_counts: dict[uuid.UUID, int] = {}
|
||||||
MAX_CONNECTIONS_PER_USER = 5
|
MAX_CONNECTIONS_PER_USER = 20
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream")
|
@router.get("/stream")
|
||||||
@@ -5,10 +5,16 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
|
from src.schemas.system import (
|
||||||
|
DatabaseHealth,
|
||||||
|
DatabaseHealthResponse,
|
||||||
|
DiskHealth,
|
||||||
|
HealthChecks,
|
||||||
|
HealthResponse,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -16,45 +22,6 @@ router = APIRouter()
|
|||||||
_start_time = time.time()
|
_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(
|
@router.get(
|
||||||
"/health",
|
"/health",
|
||||||
response_model=HealthResponse,
|
response_model=HealthResponse,
|
||||||
@@ -8,8 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models import ToolType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -9,8 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session
|
from src.auth.dependencies import get_current_user, get_db_session
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models import UserConfig
|
||||||
from src.services.notification_service import notification_service
|
from src.services.shared.notification_service import notification_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
|
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from asyncio import QueueFull
|
||||||
|
from json import JSONDecodeError
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
@@ -12,14 +14,28 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.terminal_session import TerminalSessionModel
|
from src.models import (
|
||||||
from src.models.tool_instance import ToolInstance
|
TerminalSessionModel,
|
||||||
from src.models.tool_type import ToolType
|
ToolDefinitionManifest,
|
||||||
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
|
ToolInstance,
|
||||||
|
ToolType,
|
||||||
|
)
|
||||||
|
from src.services.build.manifest_compiler import (
|
||||||
|
deep_merge,
|
||||||
|
get_manifest_container_user,
|
||||||
|
resolve_base,
|
||||||
|
)
|
||||||
|
from src.services.terminal.terminal_manager import (
|
||||||
|
MaxSessionsExceededError,
|
||||||
|
terminal_manager,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_PENDING_INPUT_MESSAGES = 64
|
||||||
|
MAX_TERMINAL_INPUT_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
class SessionRef:
|
class SessionRef:
|
||||||
"""Mutable reference to a terminal session, allowing updates during reset."""
|
"""Mutable reference to a terminal session, allowing updates during reset."""
|
||||||
@@ -57,6 +73,39 @@ async def terminal_websocket_specific(
|
|||||||
await _handle_terminal_websocket(websocket, instance_id, session_id, db_session)
|
await _handle_terminal_websocket(websocket, instance_id, session_id, db_session)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_container_user(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve the container user for docker exec from the tool manifest.
|
||||||
|
|
||||||
|
For manifest-based tools, the user declared in the manifest (or its base
|
||||||
|
definition) is returned so terminal sessions run with the same privileges
|
||||||
|
as the main container process. Legacy tools return None, preserving the
|
||||||
|
previous behavior.
|
||||||
|
"""
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
if not tool_type or tool_type.definition_type != "manifest":
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not tool_type.manifest_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
manifest_def = await db_session.get(ToolDefinitionManifest, tool_type.manifest_id)
|
||||||
|
if not manifest_def:
|
||||||
|
return None
|
||||||
|
|
||||||
|
manifest = dict(manifest_def.manifest)
|
||||||
|
if manifest_def.base_definition_id:
|
||||||
|
base_def = await db_session.get(
|
||||||
|
ToolDefinitionManifest, manifest_def.base_definition_id
|
||||||
|
)
|
||||||
|
if base_def:
|
||||||
|
manifest = resolve_base(deep_merge(dict(base_def.manifest), manifest))
|
||||||
|
|
||||||
|
return get_manifest_container_user(manifest)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_terminal_websocket(
|
async def _handle_terminal_websocket(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
instance_id: str,
|
instance_id: str,
|
||||||
@@ -139,7 +188,7 @@ async def _handle_terminal_websocket(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Fetch tool type to get startup_command
|
# Fetch tool type to get startup_command and container_user
|
||||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
startup_command = tool_type.startup_command if tool_type else None
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
if startup_command:
|
if startup_command:
|
||||||
@@ -149,6 +198,14 @@ async def _handle_terminal_websocket(
|
|||||||
startup_command,
|
startup_command,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
container_user = await _resolve_container_user(db_session, instance)
|
||||||
|
if container_user:
|
||||||
|
logger.debug(
|
||||||
|
"Terminal sessions for instance %s will run as user %s",
|
||||||
|
instance_id,
|
||||||
|
container_user,
|
||||||
|
)
|
||||||
|
|
||||||
session = None
|
session = None
|
||||||
|
|
||||||
# Get or create terminal session
|
# Get or create terminal session
|
||||||
@@ -159,6 +216,7 @@ async def _handle_terminal_websocket(
|
|||||||
instance_uuid,
|
instance_uuid,
|
||||||
instance.container_id,
|
instance.container_id,
|
||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
|
container_user=container_user,
|
||||||
)
|
)
|
||||||
slot_session_id = "default"
|
slot_session_id = "default"
|
||||||
else:
|
else:
|
||||||
@@ -189,6 +247,7 @@ async def _handle_terminal_websocket(
|
|||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
name=db_row.name,
|
name=db_row.name,
|
||||||
session_id=target_session_id,
|
session_id=target_session_id,
|
||||||
|
container_user=container_user,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -222,8 +281,7 @@ async def _handle_terminal_websocket(
|
|||||||
# Use mutable session reference so loops can survive reset
|
# Use mutable session reference so loops can survive reset
|
||||||
session_ref = SessionRef(session, slot_session_id)
|
session_ref = SessionRef(session, slot_session_id)
|
||||||
|
|
||||||
# Start I/O loops and heartbeat
|
# Start write loop and heartbeat (read is now event-driven in TerminalSession)
|
||||||
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
|
||||||
write_task = asyncio.create_task(
|
write_task = asyncio.create_task(
|
||||||
_write_loop(session_ref, websocket, instance_id)
|
_write_loop(session_ref, websocket, instance_id)
|
||||||
)
|
)
|
||||||
@@ -232,7 +290,7 @@ async def _handle_terminal_websocket(
|
|||||||
|
|
||||||
# Wait for either task to complete (indicating disconnect or error)
|
# Wait for either task to complete (indicating disconnect or error)
|
||||||
done, pending = await asyncio.wait(
|
done, pending = await asyncio.wait(
|
||||||
[read_task, write_task, heartbeat_task],
|
[write_task, heartbeat_task],
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -267,30 +325,38 @@ async def _handle_terminal_websocket(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _read_loop(session_ref: SessionRef, websocket) -> None:
|
async def _input_write_loop(input_queue: asyncio.Queue[tuple[object, bytes]]) -> None:
|
||||||
"""Read output from the container and send to WebSocket."""
|
"""Serialize PTY writes without blocking terminal control messages."""
|
||||||
try:
|
|
||||||
while True:
|
while True:
|
||||||
session = session_ref.session
|
session, data = await input_queue.get()
|
||||||
if not session.is_alive() or session._closed:
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
continue
|
|
||||||
data = await session.read_output()
|
|
||||||
if data:
|
|
||||||
try:
|
try:
|
||||||
await websocket.send_bytes(data)
|
await session.write_input(data) # type: ignore[attr-defined]
|
||||||
except WebSocketDisconnect:
|
|
||||||
break
|
|
||||||
except Exception:
|
except Exception:
|
||||||
break
|
logger.debug("Terminal input write failed", exc_info=True)
|
||||||
else:
|
finally:
|
||||||
await asyncio.sleep(0.01)
|
input_queue.task_done()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
def _queue_terminal_input(
|
||||||
|
input_queue: asyncio.Queue[tuple[object, bytes]], session: object, data: bytes
|
||||||
|
) -> bool:
|
||||||
|
"""Queue bounded terminal input without blocking control-message processing."""
|
||||||
|
if len(data) > MAX_TERMINAL_INPUT_BYTES:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
input_queue.put_nowait((session, data))
|
||||||
|
except QueueFull:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||||
"""Read input from WebSocket and send to container."""
|
"""Receive terminal messages while a dedicated worker serializes PTY input."""
|
||||||
|
input_queue: asyncio.Queue[tuple[object, bytes]] = asyncio.Queue(
|
||||||
|
maxsize=MAX_PENDING_INPUT_MESSAGES
|
||||||
|
)
|
||||||
|
input_writer = asyncio.create_task(_input_write_loop(input_queue))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
session = session_ref.session
|
session = session_ref.session
|
||||||
@@ -300,15 +366,44 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
message = await websocket.receive()
|
message = await websocket.receive()
|
||||||
if message["type"] == "websocket.receive":
|
if message["type"] == "websocket.receive":
|
||||||
if "bytes" in message:
|
if "bytes" in message:
|
||||||
await session.write_input(message["bytes"])
|
if not _queue_terminal_input(
|
||||||
|
input_queue, session, message["bytes"]
|
||||||
|
):
|
||||||
|
logger.warning("Terminal input buffer exceeded for %s", instance_id)
|
||||||
|
await websocket.close(code=1009, reason="Terminal input buffer full")
|
||||||
|
break
|
||||||
elif "text" in message:
|
elif "text" in message:
|
||||||
text = message["text"]
|
text = message["text"]
|
||||||
|
# A text frame that parses to a JSON object with a
|
||||||
|
# "type" field is a control message and must NEVER be
|
||||||
|
# written to the PTY (e.g. the heartbeat {"type":"pong"}
|
||||||
|
# must be consumed, not typed into the shell/pi). Handle
|
||||||
|
# known types and ignore unknown ones. Everything else
|
||||||
|
# (keystrokes, bracketed-paste content, plain text) is
|
||||||
|
# forwarded as raw terminal input.
|
||||||
|
ctrl = None
|
||||||
if text.startswith("{"):
|
if text.startswith("{"):
|
||||||
# Control message (JSON)
|
|
||||||
try:
|
try:
|
||||||
ctrl = json.loads(text)
|
parsed = json.loads(text)
|
||||||
msg_type = ctrl.get("type")
|
except JSONDecodeError:
|
||||||
|
parsed = None
|
||||||
|
if isinstance(parsed, dict) and "type" in parsed:
|
||||||
|
ctrl = parsed
|
||||||
|
|
||||||
|
if ctrl is None:
|
||||||
|
if not _queue_terminal_input(
|
||||||
|
input_queue, session, text.encode("utf-8")
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Terminal input buffer exceeded for %s", instance_id
|
||||||
|
)
|
||||||
|
await websocket.close(
|
||||||
|
code=1009, reason="Terminal input buffer full"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg_type = ctrl["type"]
|
||||||
if msg_type == "resize":
|
if msg_type == "resize":
|
||||||
cols = ctrl.get("cols", 80)
|
cols = ctrl.get("cols", 80)
|
||||||
rows = ctrl.get("rows", 24)
|
rows = ctrl.get("rows", 24)
|
||||||
@@ -319,6 +414,10 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
rows,
|
rows,
|
||||||
)
|
)
|
||||||
await session.resize(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":
|
elif msg_type == "reset":
|
||||||
# Reset terminal session (scoped to current slot)
|
# Reset terminal session (scoped to current slot)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -337,31 +436,30 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
startup_command=session.startup_command,
|
startup_command=session.startup_command,
|
||||||
session_id=session_ref.slot_session_id,
|
session_id=session_ref.slot_session_id,
|
||||||
name=session.name,
|
name=session.name,
|
||||||
|
container_user=session.container_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update the mutable session reference
|
# Update the mutable session reference
|
||||||
session_ref.session = new_session
|
session_ref.session = new_session
|
||||||
|
|
||||||
# Attach to new session
|
# Attach to new session
|
||||||
await terminal_manager.attach_websocket(
|
await terminal_manager.attach_websocket(new_session, websocket)
|
||||||
new_session, websocket
|
|
||||||
)
|
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "status", "status": "connected"}
|
{"type": "status", "status": "connected"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Continue the loop with the new session
|
# Continue the loop with the new session
|
||||||
continue
|
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":
|
elif message["type"] == "websocket.disconnect":
|
||||||
break
|
break
|
||||||
except Exception:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
|
except (RuntimeError, TypeError, ValueError) as exc:
|
||||||
|
logger.debug("Terminal WebSocket receive loop ended: %s", exc)
|
||||||
|
finally:
|
||||||
|
input_writer.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await input_writer
|
||||||
|
|
||||||
|
|
||||||
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
||||||
@@ -505,6 +603,7 @@ async def create_terminal_session(
|
|||||||
startup_command = tool_type.startup_command if tool_type else None
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
|
||||||
name = data.get("name")
|
name = data.get("name")
|
||||||
|
container_user = await _resolve_container_user(db_session, instance)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = await terminal_manager.create_session(
|
session = await terminal_manager.create_session(
|
||||||
@@ -512,6 +611,7 @@ async def create_terminal_session(
|
|||||||
instance.container_id,
|
instance.container_id,
|
||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
name=name,
|
name=name,
|
||||||
|
container_user=container_user,
|
||||||
)
|
)
|
||||||
except MaxSessionsExceededError:
|
except MaxSessionsExceededError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -614,6 +714,7 @@ async def reset_specific_terminal_session(
|
|||||||
# Preserve name if possible
|
# Preserve name if possible
|
||||||
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||||
name = live_session.name if live_session else None
|
name = live_session.name if live_session else None
|
||||||
|
container_user = await _resolve_container_user(db_session, instance)
|
||||||
|
|
||||||
new_session = await terminal_manager.reset_session(
|
new_session = await terminal_manager.reset_session(
|
||||||
instance_id,
|
instance_id,
|
||||||
@@ -621,6 +722,7 @@ async def reset_specific_terminal_session(
|
|||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
session_id=key[1],
|
session_id=key[1],
|
||||||
name=name,
|
name=name,
|
||||||
|
container_user=container_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -706,6 +808,7 @@ async def reset_terminal_session(
|
|||||||
# Fetch tool type to get startup_command
|
# Fetch tool type to get startup_command
|
||||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
startup_command = tool_type.startup_command if tool_type else None
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
container_user = await _resolve_container_user(db_session, instance)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Reset the default session
|
# Reset the default session
|
||||||
@@ -713,6 +816,7 @@ async def reset_terminal_session(
|
|||||||
instance_id,
|
instance_id,
|
||||||
instance.container_id,
|
instance.container_id,
|
||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
|
container_user=container_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# apps/api/src/api/tool (index)
|
||||||
|
dir: apps/api/src/api/tool
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides FastAPI REST API endpoints for managing containerized tools throughout their lifecycle, including type definitions, instance management, session tracking, and Docker operations.
|
||||||
|
## 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 containerized tools throughout their lifecycle, including type definitions, instance management, session tracking, and Docker operations.
|
||||||
|
## files
|
||||||
|
- __init__.py | Aggregates and exports router modules for the Tool API package. | 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 sessions (running instances) 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
|
||||||
|
- tool_definitions.py | Provides FastAPI CRUD endpoints for managing tool definition manifests with compilation to Dockerfile and Docker Compose previews. | 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 | FastAPI router providing REST endpoints for managing tool instances (CRUD, logs, health checks, tunnel management, proxying, and event history) within project repositories. | 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 REST endpoints for CRUD operations on tool types with validation for Docker Compose, Dockerfile, and 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 | Provides validation utilities for Docker Compose templates including YAML parsing, port exposure checking, and required template variable verification. | 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 pattern with domain-driven separation of concerns (types, instances, sessions, lifecycle) and Docker-centric validation/compilation utilities for container orchestration.
|
||||||
|
## 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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
from src.models import ToolDefinitionManifest
|
||||||
from src.models.tool_type import ToolType
|
from src.models import ToolType
|
||||||
from src.services.manifest_compiler import (
|
from src.services.build.manifest_compiler import (
|
||||||
compile_compose,
|
compile_compose,
|
||||||
compile_dockerfile,
|
compile_dockerfile,
|
||||||
compile_entrypoint,
|
compile_entrypoint,
|
||||||
@@ -173,7 +173,7 @@ async def list_tool_definitions(
|
|||||||
"""
|
"""
|
||||||
query = select(ToolDefinitionManifest)
|
query = select(ToolDefinitionManifest)
|
||||||
if not include_bases:
|
if not include_bases:
|
||||||
query = query.where(ToolDefinitionManifest.is_base == False)
|
query = query.where(ToolDefinitionManifest.is_base.is_(False))
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
query.order_by(ToolDefinitionManifest.created_at.desc())
|
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
|
import uuid
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.api.tool_types_validation import (
|
from src.api.tool.tool_types_validation import (
|
||||||
check_port_exposed,
|
check_port_exposed,
|
||||||
validate_compose_yaml,
|
validate_compose_yaml,
|
||||||
validate_required_variables,
|
validate_required_variables,
|
||||||
)
|
)
|
||||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
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.models.user import User
|
||||||
|
from src.schemas.tool import (
|
||||||
|
ToolTypeCreate,
|
||||||
|
ToolTypeResponse,
|
||||||
|
ToolTypeUpdate,
|
||||||
|
ToolTypeValidateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
||||||
|
|
||||||
@@ -29,237 +33,6 @@ async def _require_admin(user: User) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
display_name: str
|
|
||||||
description: str | None = None
|
|
||||||
default_port: int = 0
|
|
||||||
definition_type: str = "compose"
|
|
||||||
manifest_id: uuid.UUID | 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] = []
|
|
||||||
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", "manifest"):
|
|
||||||
raise ValueError(
|
|
||||||
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
|
|
||||||
)
|
|
||||||
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 or not v.strip():
|
|
||||||
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 or not v.strip():
|
|
||||||
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 == "manifest":
|
|
||||||
if self.manifest_id is None:
|
|
||||||
raise ValueError(
|
|
||||||
"manifest_id is required when definition_type is 'manifest'"
|
|
||||||
)
|
|
||||||
return self
|
|
||||||
|
|
||||||
if self.definition_type == "dockerfile" and (
|
|
||||||
self.dockerfile_template is None or not self.dockerfile_template.strip()
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"dockerfile_template is required when definition_type is 'dockerfile'"
|
|
||||||
)
|
|
||||||
if self.definition_type == "compose" and (
|
|
||||||
self.compose_template is None or not self.compose_template.strip()
|
|
||||||
):
|
|
||||||
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
|
|
||||||
manifest_id: uuid.UUID | 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", "manifest"):
|
|
||||||
raise ValueError(
|
|
||||||
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
|
|
||||||
)
|
|
||||||
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
|
|
||||||
manifest_id: uuid.UUID | None
|
|
||||||
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(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=ToolTypeResponse,
|
response_model=ToolTypeResponse,
|
||||||
@@ -461,12 +234,6 @@ async def update_tool_type(
|
|||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeValidateRequest(BaseModel):
|
|
||||||
definition_type: str
|
|
||||||
compose_template: str | None = None
|
|
||||||
dockerfile_template: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/validate",
|
"/validate",
|
||||||
summary="Validate tool type template",
|
summary="Validate tool type template",
|
||||||
+7
-1
@@ -60,7 +60,13 @@ def check_port_exposed(parsed: dict, port: int) -> bool:
|
|||||||
for service_config in parsed["services"].values():
|
for service_config in parsed["services"].values():
|
||||||
if isinstance(service_config, dict) and "ports" in service_config:
|
if isinstance(service_config, dict) and "ports" in service_config:
|
||||||
for port_mapping in service_config["ports"]:
|
for port_mapping in service_config["ports"]:
|
||||||
if isinstance(port_mapping, str) and port_str in port_mapping:
|
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
|
return True
|
||||||
elif isinstance(port_mapping, int) and port_mapping == port:
|
elif isinstance(port_mapping, int) and port_mapping == port:
|
||||||
return True
|
return True
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# apps/api/src/api/user (index)
|
||||||
|
dir: apps/api/src/api/user
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides user-facing API endpoints for authentication, profile management, and SSH key operations in the FastAPI backend.
|
||||||
|
## parent
|
||||||
|
index: apps/api/src/api/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- auth.py
|
||||||
|
- ssh_keys.py
|
||||||
|
- users.py
|
||||||
|
## links
|
||||||
|
index: apps/api/src/api/user/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/user/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change user behavior
|
||||||
|
read: __init__.py, auth.py, ssh_keys.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# apps/api/src/api/user
|
||||||
|
dir: apps/api/src/api/user
|
||||||
|
|
||||||
|
index: apps/api/src/api/user/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides user-facing API endpoints for authentication, profile management, and SSH key operations in the FastAPI backend.
|
||||||
|
## files
|
||||||
|
- __init__.py | Aggregates and exports user-related API routers from submodules for easy importing. | dep: src.api.user.auth, src.api.user.ssh_keys, src.api.user.users
|
||||||
|
- auth.py | Implements OAuth2/OIDC authentication endpoints for a FastAPI application using Authentik as the identity provider, including login initiation, callback handling, session management, logout, and current user retrieval. | exp: func:get_db_session() → AsyncGenerator[AsyncSession, None], call:SessionLocal, func:login(next) → RedirectResponse, call:Settings, call:token_urlsafe, call:build_login_redirect_url, call:logger.debug, call:RedirectResponse, call:response.set_cookie, func:callback(code: str, state: str, auth_state, auth_next, session) → RedirectResponse, call:logger.debug, call:logger.warning, call:Settings, call:httpx.AsyncClient, call:exchange_code_for_tokens, call:logger.info, call:logger.error, call:fetch_user_info, call:str, call:user_info.get, call:session.scalar, call:select(User).where, call:User, call:session.add, call:session.commit, call:session.refresh, call:create_session_cookie, call:build_cookie_options, call:cast, call:bool, call:cookie_options.get, call:RedirectResponse, call:redirect_response.set_cookie, call:redirect_response.delete_cookie, raise:HTTPException, func:logout(response: Response) → dict[str, str], call:Settings, call:build_cookie_options, call:cast, call:bool, call:str, call:cookie_options.get, call:response.delete_cookie, func:me(session_cookie, session) → dict[str, Any], call:logger.debug, call:bool, call:logger.warning, call:Settings, call:decode_session_cookie, call:session.get, call:logger.info, call:str, raise:HTTPException | dep: logging, secrets, typing, httpx, fastapi, fastapi.responses, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.cookies, src.auth.oidc, src.auth.session, src.config, src.database, src.models.user
|
||||||
|
- ssh_keys.py | FastAPI router for managing Ed25519 SSH keys including generation, storage with encrypted private keys, signing payloads, and signature verification | exp: func:_get_fernet() → Fernet, call:Settings, call:hashlib.sha256(settings.session_secret.encode()).digest, call:settings.session_secret.encode, call:base64.urlsafe_b64encode, call:Fernet, func:generate_ssh_key_pair() → tuple[str, str], call:Ed25519PrivateKey.generate, call:private_key.public_key, call:private_key.private_bytes, call:serialization.NoEncryption, call:public_key.public_bytes, call:private_bytes.decode, call:public_bytes.decode, func:create_ssh_key(data: SSHKeyCreate, user_id, session) → SSHKey, call:_get_user, call:generate_ssh_key_pair, call:_get_fernet, call:fernet.encrypt(private_key.encode()).decode, call:private_key.encode, call:SSHKey, call:session.add, call:session.commit, call:session.refresh, func:list_ssh_keys(user_id, session) → list[SSHKey], call:_get_user, call:session.execute, call:select(SSHKey).where, call:list, call:result.scalars().all, func:delete_ssh_key(key_id: uuid.UUID, user_id, session) → None, call:_get_user, call:session.get, call:session.delete, call:session.commit, raise:HTTPException, func:sign_payload(key_id: uuid.UUID, data: SignPayloadRequest, user_id, session) → SignatureResponse, call:_get_user, call:session.get, call:_get_fernet, call:fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode, call:ssh_key.private_key_encrypted.encode, call:serialization.load_ssh_private_key, call:private_key_pem.encode, call:private_key.sign, call:data.payload.encode, call:SignatureResponse, call:base64.b64encode(signature).decode, raise:HTTPException, func:verify_signature(key_id: uuid.UUID, data: VerifySignatureRequest, user_id, session) → VerifySignatureResponse, call:_get_user, call:session.get, call:serialization.load_ssh_public_key, call:ssh_key.public_key.encode, call:base64.b64decode, call:public_key.verify, call:data.payload.encode, call:VerifySignatureResponse, raise:HTTPException | dep: base64, uuid, cryptography.fernet, cryptography.hazmat.primitives, cryptography.hazmat.primitives.asymmetric.ed25519, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.config, src.models, src.schemas.project, hashlib, cryptography
|
||||||
|
- users.py | Implements FastAPI endpoints for user profile management including retrieval, updates, and avatar uploads with file validation. | exp: func:get_profile(user_id, session) → User, call:_get_user, func:update_profile(data: UserProfileUpdate, user_id, session) → User, call:_get_user, call:len, call:data.name.strip, call:data.email.strip, call:session.commit, call:session.refresh, raise:HTTPException, func:upload_avatar(file: UploadFile, user_id, session) → User, call:_get_user, call:file.read, call:len, call:Path, call:old_path.exists, call:old_path.unlink, call:filename_part.split(".")[-1].lower, call:uuid.uuid4, call:file_path.write_bytes, call:session.commit, call:session.refresh, raise:HTTPException | dep: uuid, pathlib, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models.user, src.schemas.user, sqlalchemy
|
||||||
|
## arch
|
||||||
|
Modular FastAPI router pattern with separate submodules for distinct concerns (auth, profile, SSH keys), using OAuth2/OIDC external identity provider (Authentik), encrypted key storage, and file upload handling.
|
||||||
|
## tags
|
||||||
|
user, get, call:, key, ssh, src, cookie, raise:httpexception
|
||||||
|
## symbols
|
||||||
|
- get_db_session
|
||||||
|
- login
|
||||||
|
- callback
|
||||||
|
- logout
|
||||||
|
- me
|
||||||
|
- _get_fernet
|
||||||
|
- generate_ssh_key_pair
|
||||||
|
- create_ssh_key
|
||||||
|
## workflows
|
||||||
|
- change user behavior
|
||||||
|
read: __init__.py, auth.py, ssh_keys.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""User API routers module."""
|
||||||
|
|
||||||
|
from src.api.user.auth import router as auth_router
|
||||||
|
from src.api.user.ssh_keys import router as ssh_keys_router
|
||||||
|
from src.api.user.users import router as users_router
|
||||||
|
|
||||||
|
__all__ = ["auth_router", "ssh_keys_router", "users_router"]
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
import base64
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
|
from src.schemas.project import (
|
||||||
|
SSHKeyCreate,
|
||||||
|
SSHKeyResponse,
|
||||||
|
SignPayloadRequest,
|
||||||
|
SignatureResponse,
|
||||||
|
VerifySignatureRequest,
|
||||||
|
VerifySignatureResponse,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||||
|
|
||||||
@@ -53,36 +59,6 @@ def generate_ssh_key_pair() -> tuple[str, str]:
|
|||||||
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
|
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
class SSHKeyCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
class SSHKeyResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
public_key: str
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class SignPayloadRequest(BaseModel):
|
|
||||||
payload: str
|
|
||||||
|
|
||||||
|
|
||||||
class SignatureResponse(BaseModel):
|
|
||||||
signature: str
|
|
||||||
|
|
||||||
|
|
||||||
class VerifySignatureRequest(BaseModel):
|
|
||||||
payload: str
|
|
||||||
signature: str
|
|
||||||
|
|
||||||
|
|
||||||
class VerifySignatureResponse(BaseModel):
|
|
||||||
valid: bool
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=SSHKeyResponse,
|
response_model=SSHKeyResponse,
|
||||||
@@ -171,7 +147,9 @@ async def delete_ssh_key(
|
|||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
ssh_key = await session.get(SSHKey, key_id)
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(ssh_key)
|
await session.delete(ssh_key)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -203,7 +181,9 @@ async def sign_payload(
|
|||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
ssh_key = await session.get(SSHKey, key_id)
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||||
|
)
|
||||||
|
|
||||||
fernet = _get_fernet()
|
fernet = _get_fernet()
|
||||||
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
@@ -242,7 +222,9 @@ async def verify_signature(
|
|||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
ssh_key = await session.get(SSHKey, key_id)
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||||
|
)
|
||||||
|
|
||||||
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
||||||
|
|
||||||
@@ -2,11 +2,11 @@ import uuid
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
from src.schemas.user import UserProfileResponse, UserProfileUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
@@ -16,20 +16,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
|
|||||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
||||||
|
|
||||||
|
|
||||||
class UserProfileResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
email: str
|
|
||||||
name: str
|
|
||||||
avatar_url: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfileUpdate(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/me",
|
"/me",
|
||||||
response_model=UserProfileResponse,
|
response_model=UserProfileResponse,
|
||||||
@@ -77,12 +63,16 @@ async def update_profile(
|
|||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
if len(data.name.strip()) == 0:
|
if len(data.name.strip()) == 0:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty"
|
||||||
|
)
|
||||||
user.name = data.name.strip()
|
user.name = data.name.strip()
|
||||||
|
|
||||||
if data.email is not None:
|
if data.email is not None:
|
||||||
if "@" not in data.email:
|
if "@" not in data.email:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email"
|
||||||
|
)
|
||||||
user.email = data.email.strip()
|
user.email = data.email.strip()
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# apps/api/src/api/workspace (index)
|
||||||
|
dir: apps/api/src/api/workspace
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides REST API endpoints for managing user workspaces, including file operations, Git version control, tool instances, and workspace lifecycle management.
|
||||||
|
## parent
|
||||||
|
index: apps/api/src/api/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- workspace_files.py
|
||||||
|
- workspace_git.py
|
||||||
|
- workspace_instances.py
|
||||||
|
- workspaces.py
|
||||||
|
## links
|
||||||
|
index: apps/api/src/api/workspace/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/workspace/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change workspace behavior
|
||||||
|
read: __init__.py, workspace_files.py, workspace_git.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# apps/api/src/api/workspace
|
||||||
|
dir: apps/api/src/api/workspace
|
||||||
|
|
||||||
|
index: apps/api/src/api/workspace/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides REST API endpoints for managing user workspaces, including file operations, Git version control, tool instances, and workspace lifecycle management.
|
||||||
|
## files
|
||||||
|
- __init__.py | Aggregates and re-exports workspace-related API routers from submodules for centralized access. | dep: src.api.workspace.workspace_files, src.api.workspace.workspace_git, src.api.workspace.workspace_instances, src.api.workspace.workspaces
|
||||||
|
- workspace_files.py | Provides FastAPI endpoints for listing, reading, and writing files within user-owned workspaces with optional Git commit integration. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:list_files(workspace_id: uuid.UUID, path, user_id, session) → dict, call:_get_workspace, call:FileService, call:service.list_directory, raise:HTTPException, func:get_file_content(workspace_id: uuid.UUID, path: str, user_id, session) → dict, call:_get_workspace, call:FileService, call:service.read_file, raise:HTTPException, func:write_file(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:FileService, call:data.get("path", "").strip, call:data.get("message", "").strip, call:service.write_file, call:GitOperations, call:git.commit, raise:HTTPException | dep: uuid, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.shared.file_service, sqlalchemy, src.services.git.git_operations
|
||||||
|
- workspace_git.py | Provides FastAPI REST endpoints for executing git operations (status, branches, commit, push, pull, fetch, checkout, history) on user workspaces with authentication and database persistence. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:git_status(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.status, raise:HTTPException, func:git_branches(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.branches, raise:HTTPException, func:git_commit(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:data.get("message", "").strip, call:GitOperations, call:git.commit, raise:HTTPException, func:git_push(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.push, raise:HTTPException, func:git_pull(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.pull, raise:HTTPException, func:git_fetch(workspace_id: uuid.UUID, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.fetch, raise:HTTPException, func:git_checkout(workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_workspace, call:data.get("branch", "").strip, call:GitOperations, call:git.checkout, call:session.commit, raise:HTTPException, func:git_history(workspace_id: uuid.UUID, path, limit, user_id, session) → dict, call:_get_workspace, call:GitOperations, call:git.history, raise:HTTPException | dep: uuid, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.services.git.git_operations, sqlalchemy
|
||||||
|
- workspace_instances.py | Defines FastAPI endpoints for creating and listing tool instances associated with a specific workspace. | exp: func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, user_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException, func:create_workspace_instance(workspace_id: uuid.UUID, data: CreateWorkspaceInstanceRequest, user_id, session) → dict, call:_get_workspace, call:session.get, call:CreateInstanceRequest, call:str, call:create_tool_instance, call:instance.created_at.isoformat, raise:HTTPException, func:list_workspace_instances(workspace_id: uuid.UUID, user_id, session) → list[dict], call:_get_workspace, call:session.execute, call:select(ToolInstance) .where(ToolInstance.workspace_id == workspace_id) .order_by, call:ToolInstance.created_at.desc, call:result.scalars().all, call:str, call:i.created_at.isoformat | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.schemas.tool, src.services.tool.instance_service
|
||||||
|
- workspaces.py | FastAPI router providing CRUD endpoints for managing Git workspaces with repository integration, instance tracking, and sync capabilities. | exp: func:list_all_workspaces(user_id, session) → list[dict], call:select(func.count(ToolInstance.id)) .where(ToolInstance.workspace_id == Workspace.id) .correlate(Workspace) .scalar_subquery, call:func.count, call:session.execute, call:select( Workspace, GitRepository, instance_count.label("instance_count"), ) .join(GitRepository, Workspace.repo_id == GitRepository.id) .options(selectinload(GitRepository.project)) .where(Workspace.user_id == user_id) .order_by, call:instance_count.label, call:selectinload, call:Workspace.created_at.desc, call:result.all, call:str, call:ws.last_sync_at.isoformat, call:ws.created_at.isoformat, call:ws.updated_at.isoformat, func:delete_workspace_top_level(workspace_id: uuid.UUID, force, user_id, session) → dict, call:session.get, call:WorkspaceManager, call:manager.delete, call:session.commit, call:session.rollback, call:logger.error, raise:HTTPException, func:create_workspace_top_level(data: dict, user_id, session) → dict, call:data.get("repo_id", "").strip, call:uuid.UUID, call:session.get, call:data.get("name", "").strip, call:data.get("branch", "main").strip, call:WorkspaceManager, call:manager.create, call:session.add, call:session.commit, call:session.rollback, call:logger.error, call:session.refresh, call:str, call:workspace.created_at.isoformat, raise:HTTPException, func:list_workspaces(project_id: uuid.UUID, repo_id: uuid.UUID, user_id, session) → list[dict], call:_get_repo, call:select(func.count(ToolInstance.id)) .where(ToolInstance.workspace_id == Workspace.id) .correlate(Workspace) .scalar_subquery, call:func.count, call:session.execute, call:select( Workspace, instance_count.label("instance_count"), ) .where(Workspace.repo_id == repo_id) .order_by, call:instance_count.label, call:Workspace.created_at.desc, call:result.all, call:str, call:ws.last_sync_at.isoformat, call:ws.created_at.isoformat, call:ws.updated_at.isoformat, func:create_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_repo, call:data.get("name", "").strip, call:data.get("branch", "main").strip, call:WorkspaceManager, call:manager.create, call:session.add, call:session.commit, call:session.rollback, call:logger.error, call:session.refresh, call:str, call:workspace.created_at.isoformat, raise:HTTPException, func:get_workspace_detail(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:session.execute, call:select(func.count(ToolInstance.id)).where, call:func.count, call:result.scalar, call:str, call:workspace.last_sync_at.isoformat, call:workspace.created_at.isoformat, call:workspace.updated_at.isoformat, func:update_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, data: dict, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:data.get("name", "").strip, call:data.get("branch", "").strip, call:session.commit, call:session.rollback, call:logger.error, call:str, raise:HTTPException, func:delete_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, force, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:WorkspaceManager, call:manager.delete, call:session.commit, call:session.rollback, call:logger.error, raise:HTTPException, func:sync_workspace(project_id: uuid.UUID, repo_id: uuid.UUID, workspace_id: uuid.UUID, user_id, session) → dict, call:_get_repo, call:_get_workspace, call:WorkspaceManager, call:manager.sync, call:session.commit, call:workspace.last_sync_at.isoformat, raise:HTTPException, func:_get_repo(session: AsyncSession, repo_id: uuid.UUID, project_id: uuid.UUID, user_id: uuid.UUID) → GitRepository, call:session.execute, call:select(GitRepository) .where( GitRepository.id == repo_id, GitRepository.project_id == project_id, ) .options, call:selectinload, call:result.scalar_one_or_none, raise:HTTPException, func:_get_workspace(session: AsyncSession, workspace_id: uuid.UUID, repo_id: uuid.UUID) → Workspace, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, raise:HTTPException | dep: logging, uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, sqlalchemy.orm, src.auth.dependencies, src.models, src.services.shared.workspace_manager
|
||||||
|
## arch
|
||||||
|
Modular FastAPI router decomposition with domain-driven vertical slicing (files/git/instances/workspaces as separate modules), aggregated through __init__.py barrel exports, combining database persistence with external Git repository integration.
|
||||||
|
## tags
|
||||||
|
workspace, get, raise:httpexception, call:, at.isoformat, git, call:select, call:data.get
|
||||||
|
## symbols
|
||||||
|
- _get_workspace
|
||||||
|
- list_files
|
||||||
|
- get_file_content
|
||||||
|
- write_file
|
||||||
|
- git_status
|
||||||
|
- git_branches
|
||||||
|
- git_commit
|
||||||
|
- git_push
|
||||||
|
## workflows
|
||||||
|
- change workspace behavior
|
||||||
|
read: __init__.py, workspace_files.py, workspace_git.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Workspace API routers module."""
|
||||||
|
|
||||||
|
from src.api.workspace.workspace_files import router as workspace_files_router
|
||||||
|
from src.api.workspace.workspace_git import router as workspace_git_router
|
||||||
|
from src.api.workspace.workspace_instances import router as workspace_instances_router
|
||||||
|
from src.api.workspace.workspaces import (
|
||||||
|
all_workspaces_router,
|
||||||
|
router as workspaces_router,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"all_workspaces_router",
|
||||||
|
"workspace_files_router",
|
||||||
|
"workspace_git_router",
|
||||||
|
"workspace_instances_router",
|
||||||
|
"workspaces_router",
|
||||||
|
]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Workspace file API endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models import Workspace
|
||||||
|
from src.services.shared.file_service import FileService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_workspace(
|
||||||
|
session: AsyncSession,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> Workspace:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Workspace).where(
|
||||||
|
Workspace.id == workspace_id,
|
||||||
|
Workspace.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workspace = result.scalar_one_or_none()
|
||||||
|
if not workspace:
|
||||||
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_files(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
path: str = "",
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List files in a workspace directory."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
service = FileService()
|
||||||
|
try:
|
||||||
|
entries = service.list_directory(workspace, path)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": e.name,
|
||||||
|
"path": e.path,
|
||||||
|
"type": e.type,
|
||||||
|
"size": e.size,
|
||||||
|
}
|
||||||
|
for e in entries
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/content")
|
||||||
|
async def get_file_content(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
path: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get the content of a text file."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
service = FileService()
|
||||||
|
try:
|
||||||
|
content = service.read_file(workspace, path)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"content": content, "path": path}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/content")
|
||||||
|
async def write_file(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Write a file and optionally commit."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
service = FileService()
|
||||||
|
|
||||||
|
file_path = data.get("path", "").strip()
|
||||||
|
content = data.get("content", "")
|
||||||
|
commit_message = data.get("message", "").strip()
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
raise HTTPException(status_code=400, detail="File path is required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
service.write_file(workspace, file_path, content)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
if commit_message:
|
||||||
|
from src.services.git.git_operations import GitOperations
|
||||||
|
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.commit(commit_message)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "saved", "path": file_path}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Workspace git API endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models import Workspace
|
||||||
|
from src.services.git.git_operations import GitOperations
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_workspace(
|
||||||
|
session: AsyncSession,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> Workspace:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Workspace).where(
|
||||||
|
Workspace.id == workspace_id,
|
||||||
|
Workspace.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workspace = result.scalar_one_or_none()
|
||||||
|
if not workspace:
|
||||||
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def git_status(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get git status for the workspace."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
status = await git.status()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"branch": status.branch,
|
||||||
|
"modified": status.modified,
|
||||||
|
"added": status.added,
|
||||||
|
"deleted": status.deleted,
|
||||||
|
"untracked": status.untracked,
|
||||||
|
"ahead": status.ahead,
|
||||||
|
"behind": status.behind,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/branches")
|
||||||
|
async def git_branches(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List branches for the workspace."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
branches, current = await git.branches()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"branches": branches,
|
||||||
|
"current_branch": current,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/commit")
|
||||||
|
async def git_commit(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Stage all changes and commit."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
message = data.get("message", "").strip()
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status_code=400, detail="Commit message is required")
|
||||||
|
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.commit(message)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "committed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/push")
|
||||||
|
async def git_push(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Push current branch."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.push()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "pushed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pull")
|
||||||
|
async def git_pull(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Pull current branch."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.pull()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "pulled"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fetch")
|
||||||
|
async def git_fetch(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Fetch from origin."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.fetch()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "fetched"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/checkout")
|
||||||
|
async def git_checkout(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Checkout a branch."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
branch = data.get("branch", "").strip()
|
||||||
|
if not branch:
|
||||||
|
raise HTTPException(status_code=400, detail="Branch name is required")
|
||||||
|
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.checkout(branch)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
workspace.branch = branch
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {"status": "checked_out", "branch": branch}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def git_history(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
path: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get commit history."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
commits = await git.history(path, limit)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"commits": [
|
||||||
|
{
|
||||||
|
"hash": c.hash,
|
||||||
|
"message": c.message,
|
||||||
|
"author": c.author,
|
||||||
|
"date": c.date,
|
||||||
|
}
|
||||||
|
for c in commits
|
||||||
|
],
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user