Compare commits
531 Commits
main
..
7f78d92fef
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f78d92fef | |||
| bbfcde3d1b | |||
| 9f4e9845c0 | |||
| ce8b5dc86d | |||
| efb62fe41a | |||
| c26e9eacfa | |||
| aa49efb236 | |||
| 6b947b7593 | |||
| 3da7ea6408 | |||
| 79aabd6f43 | |||
| 40ac931c65 | |||
| aa51ba219b | |||
| 7623f29ffb | |||
| 3979b1f4e7 | |||
| caadd59441 | |||
| 30549f4863 | |||
| 070cd4d5a5 | |||
| 44ef62271e | |||
| 7440720b7b | |||
| 110844e597 | |||
| b4d08b0232 | |||
| f1180f6053 | |||
| 59bec31046 | |||
| 8a7bec8df2 | |||
| c881bbdad3 | |||
| f1b968bb88 | |||
| 3c222d4f0f | |||
| 1d10283fc9 | |||
| 886be83af5 | |||
| 82091e31a8 | |||
| b2c84e2064 | |||
| 680417a0a2 | |||
| e5e29aca49 | |||
| 486f3cbc44 | |||
| 152f87a254 | |||
| 349066bcfa | |||
| c353bceb97 | |||
| 64dcdc9d0d | |||
| 4ea2e3659d | |||
| 734bd9529a | |||
| 1ef9d66eed | |||
| 2169b24875 | |||
| f2a3399f27 | |||
| 5266e64be2 | |||
| 9a17916dd2 | |||
| a388a8bec9 | |||
| 51a98a0c63 | |||
| 6efe524974 | |||
| 88c56a83b7 | |||
| b4aa4c5fcb | |||
| 183e910afd | |||
| d80ee4157c | |||
| d8ab7734cb | |||
| de6a6a3b00 | |||
| 9503f6cb4f | |||
| 6b118307eb | |||
| 070e960c05 | |||
| 96ce3f4c53 | |||
| e49d049455 | |||
| e7f219f7c3 | |||
| d7d5baa41a | |||
| 61072f4c07 | |||
| 7070867393 | |||
| d472c41092 | |||
| a9e2dd3552 | |||
| 6553a8845b | |||
| 994b1cf3b7 | |||
| 6aea83bf17 | |||
| 8d51877afa | |||
| 597cfb9573 | |||
| 703cf1f88b | |||
| 5dc7d44111 | |||
| ee348643f8 | |||
| 05a598812b | |||
| 7515d9106f | |||
| 8c7affc933 | |||
| 2680a8c44a | |||
| 1021d61be3 | |||
| 7224afafd1 | |||
| 020f832eed | |||
| 7fe2790199 | |||
| 38c51ed95e | |||
| 37ccaa4fdc | |||
| 8816ee02ce | |||
| 6104f592eb | |||
| 0591b00ded | |||
| 0127d283a6 | |||
| 2757ef3b4f | |||
| ab55da280c | |||
| 4e076c36d2 | |||
| c6d62f84da | |||
| 8a0d82f49b | |||
| 0c74997cfe | |||
| fc72c5f6e9 | |||
| 51a399c775 | |||
| fc75eeb76d | |||
| 37134b8c18 | |||
| c6b804bf0a | |||
| c754984df8 | |||
| 906aab3b73 | |||
| e1aaf9f6fc | |||
| 6170306d9e | |||
| 04cd9ff472 | |||
| 1bf42a7feb | |||
| 56dd7d3fd3 | |||
| 8837031fd2 | |||
| a0cfbbc2d2 | |||
| 280a6ff2fa | |||
| 78e808bc54 | |||
| c1445976d7 | |||
| ff8aa2a4f5 | |||
| 398436ecb5 | |||
| 06a4a27880 | |||
| f34c733706 | |||
| 95efa5d029 | |||
| 9a036f1968 | |||
| ec6d4ad496 | |||
| d70b8e2363 | |||
| ee3c5af7a4 | |||
| b02cd978c3 | |||
| e956d7c30d | |||
| b8fc4e6642 | |||
| ab1843b1c3 | |||
| 88a973dc68 | |||
| 27c77af591 | |||
| e7587ca9f5 | |||
| 59b125d8e2 | |||
| b05de96569 | |||
| a5d64d1859 | |||
| 5bba2bbd92 | |||
| 986091ac56 | |||
| 47b1af8e92 | |||
| d567225bf7 | |||
| d2b6bba15c | |||
| b7396d58d2 | |||
| 351e76c00d | |||
| 2c2c4f3683 | |||
| fdf78353ad | |||
| 4cc433a1b8 | |||
| 321b4e3d0e | |||
| 2e156fc534 | |||
| cc52811522 | |||
| 9cab8c7bc7 | |||
| eeb7d9a1b2 | |||
| 6cf06d2380 | |||
| 401ad2e65d | |||
| 4814ec2363 | |||
| 98b9d612fa | |||
| 874873541d | |||
| ef9ac76f06 | |||
| ca9db195de | |||
| c1e16f2163 | |||
| 61d32fa00f | |||
| cddb3f8ccf | |||
| 87a938fe58 | |||
| 9157694412 | |||
| aa34314175 | |||
| c7fc386d0f | |||
| 6bd814e346 | |||
| aa25852091 | |||
| c2740cd282 | |||
| 23875bb3cc | |||
| ee1eab8408 | |||
| 2254ba7496 | |||
| 4866ad08b1 | |||
| 97ebc19313 | |||
| 946ac6f66a | |||
| 90ddee14c2 | |||
| f17f8ae8c8 | |||
| d713bfc5f9 | |||
| 27fe8c24ec | |||
| eef1e4e8c6 | |||
| a7a5905874 | |||
| 021537de56 | |||
| fdfd75790d | |||
| 3d1f8d9cf7 | |||
| eec37ab710 | |||
| 5f499ec1b0 | |||
| 2b5223097f | |||
| 1efbc289ba | |||
| 3c57c8b78b | |||
| 9c4500f9cb | |||
| 1e2c5a68cf | |||
| dc6991e6ef | |||
| cdf233378c | |||
| 23769e6ad4 | |||
| 9f8058223a | |||
| b483a34517 | |||
| a8fbca9ef5 | |||
| de8c47c81c | |||
| b11089896a | |||
| 16549709e2 | |||
| 68977b73be | |||
| 3da2bc93cb | |||
| d9632a3412 | |||
| 03d22c4d06 | |||
| 19242b4152 | |||
| ceaed9af66 | |||
| e9364fa70f | |||
| 2bec205a30 | |||
| cbd3436ff7 | |||
| 57ff236f2d | |||
| 6085859874 | |||
| d413fb84a5 | |||
| c22b047b8c | |||
| 090edf7ef6 | |||
| cbaebcf649 | |||
| 4a0d38384f | |||
| ea006b68c2 | |||
| 202533fbb1 | |||
| 0952aa8217 | |||
| 787e8844bc | |||
| fe98f966d6 | |||
| 79ad3b0715 | |||
| f728011b2a | |||
| 569876538a | |||
| d2b1c132d1 | |||
| 8926152fca | |||
| 2682e0268c | |||
| f13a63dc2f | |||
| 4a7f24348c | |||
| 0fdbef578f | |||
| 29a12bb102 | |||
| 270764ff0f | |||
| 0e6521e433 | |||
| e20d94d6ba | |||
| f4802ece4d | |||
| 9800e37cd6 | |||
| 84f30b07c4 | |||
| fba5e7c7be | |||
| 1e7bd0a540 | |||
| 0a0af4e02a | |||
| 3aa56dcfc3 | |||
| 7e3c701ea6 | |||
| e672bdde54 | |||
| c7c4cb45a7 | |||
| 6e4275a510 | |||
| 3ef60be623 | |||
| a3d01dd0a5 | |||
| 9bd5fc5c68 | |||
| b6e71e32f5 | |||
| 9ccaae04db | |||
| 9f90624aa6 | |||
| 62c1fb3836 | |||
| 569c20cf63 | |||
| f658b71079 | |||
| 3e99e7f197 | |||
| c2c983a01e | |||
| e46b4f9249 | |||
| 8eb851793d | |||
| 8e5e815ac9 | |||
| 143a254b0c | |||
| 5deee8c65c | |||
| 62d1bdc462 | |||
| 0b35ae3bf0 | |||
| b55300ff6f | |||
| 314ba3aee4 | |||
| 18e4a89573 | |||
| 29943ac239 | |||
| 22474cdba5 | |||
| 0c839e8c6f | |||
| c63cf7db50 | |||
| d9d2b91384 | |||
| d6ea5fb1fd | |||
| 1883825b18 | |||
| bc71fd6fac | |||
| 28aa9ccf5a | |||
| 44dd80cb58 | |||
| 23485833d8 | |||
| e23dcdf4e1 | |||
| f05ac55875 | |||
| bcefeb4163 | |||
| 33d08faf70 | |||
| 8a58c61278 | |||
| 8231e750d9 | |||
| 6ce645d210 | |||
| 89ca9f10c7 | |||
| baabd1fa62 | |||
| f14fc37e75 | |||
| e07938098a | |||
| 943b9db5c7 | |||
| a4604d6a9a | |||
| 18204628cc | |||
| 93b415c53e | |||
| e7adfb462b | |||
| ed1d6528c6 | |||
| c4be7163d6 | |||
| 13f55fff47 | |||
| 0ec20b9c23 | |||
| 4c11163bff | |||
| adda76a2ff | |||
| 47962ed476 | |||
| 6a0c9bd669 | |||
| 76fbf0a755 | |||
| 187193fa6e | |||
| cd9c9539a2 | |||
| 555517c144 | |||
| fc1554140f | |||
| bc5e80c954 | |||
| ab79080f0b | |||
| 4c216dd1ca | |||
| a37a3122f9 | |||
| a905cf729e | |||
| 3a16775188 | |||
| b363d89768 | |||
| 27c39f9cfc | |||
| e8d5b16acc | |||
| 437ad840ef | |||
| c2a232d8f0 | |||
| adaedb70ef | |||
| 5178cf9cbf | |||
| 01a0ef46c9 | |||
| a4c429d53a | |||
| 1fc244e818 | |||
| 8fb4b67372 | |||
| fbd41e3eb4 | |||
| 9c57a94e9f | |||
| 84b7b64ec0 | |||
| 29ed0f2a3b | |||
| 6c32e5266c | |||
| 9e88acaa36 | |||
| 0d10caf489 | |||
| 245d79569e | |||
| 8e86cd255c | |||
| 6ee667c384 | |||
| 21285498ae | |||
| 9751b65dce | |||
| 612217ad89 | |||
| b9ea806c0d | |||
| 22f736ce20 | |||
| deb22bec0f | |||
| 9320e175d1 | |||
| d8f220d825 | |||
| 77b7c82563 | |||
| 3fc392a314 | |||
| edd8882fa0 | |||
| 760369b102 | |||
| 8da527f964 | |||
| 76fbd74d20 | |||
| 384beeca8a | |||
| 869efda214 | |||
| 51b5d723ac | |||
| 33b482ce91 | |||
| 0cb2eefd29 | |||
| 5f5dc9c851 | |||
| 4864d269e8 | |||
| 29e4bed9e6 | |||
| c8da0ab6c4 | |||
| b04a458975 | |||
| 2299bd51ba | |||
| 45fb0c753c | |||
| c82e628e6f | |||
| 383a874bcf | |||
| 18634387c7 | |||
| 4b09f611d6 | |||
| 5c998f5bf9 | |||
| c595a513d5 | |||
| 08bd8bf7f9 | |||
| 1b7308d091 | |||
| 1060cb60ed | |||
| b58696bb7e | |||
| 4d0834069b | |||
| fc41b51bf0 | |||
| 3dbb6321fc | |||
| 7282b91d99 | |||
| 9ad11a021c | |||
| 9f29ac15da | |||
| 3092038e40 | |||
| 738e01bb7c | |||
| 4d0c70de98 | |||
| 701bd57293 | |||
| 85f04447ea | |||
| d931f3071d | |||
| 4a356fe88e | |||
| ca22e9c9d2 | |||
| 4de312c170 | |||
| e7a89a853f | |||
| 7b23618ae7 | |||
| 8b4e1a7428 | |||
| f37813a317 | |||
| 6e600fdbcd | |||
| f4211ad452 | |||
| 058c501e4a | |||
| 74033243c9 | |||
| 9910fd4445 | |||
| 4a66a4a384 | |||
| fa20d00d14 | |||
| f59274ae64 | |||
| b89fb608b6 | |||
| 116cd22ff8 | |||
| 0094ba01cd | |||
| f6fb984ec6 | |||
| 0dba13a354 | |||
| 5500552993 | |||
| fed49dba6d | |||
| fa17b13413 | |||
| 38185b9659 | |||
| 07e7c6ea0f | |||
| b638dccd36 | |||
| 8f7e19fdb1 | |||
| ba76f09a1c | |||
| 8b08c3886c | |||
| 5577e19782 | |||
| 268651bab0 | |||
| fc873e2d6b | |||
| 7389344b6d | |||
| 0a8f1419a6 | |||
| 09938bede4 | |||
| 073013bc61 | |||
| 865b9411da | |||
| 48d1a7d05c | |||
| ab1d3a6aa1 | |||
| d117047711 | |||
| d1c187ab16 | |||
| ecd3ba5918 | |||
| a919ff8611 | |||
| 39cf01c3c9 | |||
| c49bb028c4 | |||
| 0baf7f7750 | |||
| 6e496e102d | |||
| 7cb88a3163 | |||
| ce1a73abce | |||
| 5fddc65468 | |||
| f065e2b8c0 | |||
| f4cf286bb5 | |||
| 549d13f469 | |||
| 45cd192188 | |||
| 030a39dd5a | |||
| ed15d53493 | |||
| ffb7ef0d21 | |||
| e265c86997 | |||
| 23c876b558 | |||
| 74c65068c8 | |||
| 48277369f2 | |||
| 5f39267781 | |||
| caf11cdb8a | |||
| 2f44306089 | |||
| 6173d42ddf | |||
| 9afd559394 | |||
| 25da0c149a | |||
| 457f0d29ae | |||
| dfbd3e60a8 | |||
| 075eb6a76b | |||
| 4571bebf8e | |||
| ec45283257 | |||
| cce3fa773a | |||
| 50474f7b13 | |||
| 9c7043bab1 | |||
| 2dd2f2ab06 | |||
| e555561a2d | |||
| 4ac3d593aa | |||
| 1a3860a4d8 | |||
| 3d9ff44d1a | |||
| 12378def4d | |||
| f6f7853aa4 | |||
| 802d8f1e8c | |||
| 4f9aa7e3c2 | |||
| cb25b94cb7 | |||
| 01aaf4c78f | |||
| 96c8dd7402 | |||
| f5c2c95af0 | |||
| 06fe8623bc | |||
| f2fed518f0 | |||
| cc2a638c76 | |||
| 5cec4a7a6f | |||
| 7e0df57f8c | |||
| aea1ff95f6 | |||
| c8fdca7f60 | |||
| 014b88ee56 | |||
| b6bda3d692 | |||
| d7fb51f427 | |||
| 0d57e3501a | |||
| 3672312028 | |||
| c5f117e5b1 | |||
| 10a5c29702 | |||
| 312a646b89 | |||
| 01adc9a00f | |||
| 2e9ca52cdb | |||
| cd4eba9803 | |||
| 18646e3d1b | |||
| 8c5e1b931e | |||
| d1be2e4951 | |||
| 953ea05756 | |||
| 507b71c586 | |||
| aebcf25bf4 | |||
| ae42cac61e | |||
| e2ad7d7fb6 | |||
| 9e1334eb6d | |||
| c41993310b | |||
| cb25f21c44 | |||
| 392e85ead4 | |||
| a4bf8afac9 | |||
| a559470369 | |||
| 8cab17472e | |||
| e9d404b1ff | |||
| dab6c74046 | |||
| ca8b255148 | |||
| 02a2ad6df5 | |||
| 4ef0f108ea | |||
| dc8ef0e463 | |||
| 6a7657aeda | |||
| eca8b8815b | |||
| d0f7a97f92 | |||
| 765cb965e6 | |||
| 063a839790 | |||
| ae41a64e66 | |||
| 0901b1e832 | |||
| 7cc720786e | |||
| e167a6be12 | |||
| 0fa926284c | |||
| 5c17de0c3c | |||
| 8efadc4432 | |||
| 1e40540ef4 | |||
| 7cbbb41661 | |||
| 952a9f3234 | |||
| ab8872f79e | |||
| be4893e2a7 | |||
| b3c6a5fdc9 | |||
| b7d17cea78 | |||
| 36d6448f5f | |||
| 20a5f6a9a1 | |||
| 1c94583307 | |||
| 95a7454bee | |||
| 649496b762 | |||
| d13e16f5e1 | |||
| d5f9df33b7 | |||
| 468e0eacda | |||
| 2a9e57ad0d | |||
| e4c5e7f2db | |||
| 70957e462a | |||
| 684a11610a |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"fingerprint": "639c16d45210921c3c8ece071ef18bbe0c426ea2"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Skill Registry — workspace
|
||||||
|
|
||||||
|
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
||||||
|
|
||||||
|
Last updated: 2026-06-05
|
||||||
|
|
||||||
|
## Sources scanned
|
||||||
|
|
||||||
|
- .opencode/skills
|
||||||
|
- .claude/skills
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
**Delegator use only.** This registry is an index, not a summary. Any agent that launches subagents reads it to select relevant skills, then passes exact `SKILL.md` paths for the subagent to read before work.
|
||||||
|
|
||||||
|
`SKILL.md` remains the source of truth. Do not inject generated summaries or compact rules by default; pass paths so subagents load the full runtime contract and preserve author intent.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
| Skill | Trigger / description | Scope | Path |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/workspace/.opencode/skills/openspec-apply-change/SKILL.md` |
|
||||||
|
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/workspace/.opencode/skills/openspec-archive-change/SKILL.md` |
|
||||||
|
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/workspace/.opencode/skills/openspec-explore/SKILL.md` |
|
||||||
|
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/workspace/.opencode/skills/openspec-propose/SKILL.md` |
|
||||||
|
| `sift-backlog` | Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open. | project | `/workspace/.claude/skills/sift-backlog/SKILL.md` |
|
||||||
|
|
||||||
|
## Loading protocol
|
||||||
|
|
||||||
|
1. Match task context and target files against the `Trigger / description` column.
|
||||||
|
2. Pass only the matching `Path` values to the subagent under `## Skills to load before work`.
|
||||||
|
3. Instruct the subagent to read those exact `SKILL.md` files before reading, writing, reviewing, testing, or creating artifacts.
|
||||||
|
4. If no matching skill exists, proceed without project skill injection and report `skill_resolution: none`.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# .claude (index)
|
||||||
|
dir: .claude
|
||||||
|
|
||||||
|
## role
|
||||||
|
Configuration and instruction files for the Claude AI assistant integration.
|
||||||
|
## 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 and instruction files for the Claude AI assistant integration.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Simple flat-file configuration storage with no complex architecture; contains prompt templates, system instructions, or context files used to guide Claude's behavior in this project.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# .claude/skills (index)
|
||||||
|
dir: .claude/skills
|
||||||
|
|
||||||
|
## role
|
||||||
|
Empty directory placeholder for future Claude Code skill definitions or custom AI-assisted workflows.
|
||||||
|
## 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
|
||||||
|
Empty directory placeholder for future Claude Code skill definitions or custom AI-assisted workflows.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
None (no files present); likely intended for declarative skill modules using Claude's skill system pattern.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .claude/skills/sift-backlog (index)
|
||||||
|
dir: .claude/skills/sift-backlog
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using a custom CLI tool.
|
||||||
|
## parent
|
||||||
|
index: .claude/skills/.pi-map.index.md
|
||||||
|
map: .claude/skills/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- SKILL.md
|
||||||
|
## links
|
||||||
|
index: .claude/skills/sift-backlog/.pi-map.index.md
|
||||||
|
map: .claude/skills/sift-backlog/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .claude/skills/sift-backlog
|
||||||
|
dir: .claude/skills/sift-backlog
|
||||||
|
|
||||||
|
index: .claude/skills/sift-backlog/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using a custom CLI tool.
|
||||||
|
## files
|
||||||
|
- SKILL.md | Defines a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using a custom CLI tool. | dep: sf (custom CLI tool), task management system, plan management system
|
||||||
|
## arch
|
||||||
|
Skill-based workflow automation following a structured triage pipeline (scan → filter → prioritize → activate) with CLI tool integration for backlog management.
|
||||||
|
## tags
|
||||||
|
skill, defines, workflow, triaging, organizing, activating, backlog, tasks
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
---
|
||||||
|
name: sift-backlog
|
||||||
|
description: 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.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sift Backlog
|
||||||
|
|
||||||
|
Triage backlog tasks: prioritize, group into plans, set dependencies, and activate.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
1. List backlog tasks (`sf task backlog`)
|
||||||
|
2. Clarify and enrich each task (titles, descriptions)
|
||||||
|
3. Identify groupings and create draft plans
|
||||||
|
4. Add tasks to plans and set dependencies
|
||||||
|
5. Activate plans
|
||||||
|
6. Set task status to open
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1: List Backlog Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf task backlog
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Clarify and Enrich Tasks
|
||||||
|
|
||||||
|
Backlog tasks often have only a brief title with no description. Before organizing, ensure each task is well-defined.
|
||||||
|
|
||||||
|
**For each task, evaluate:**
|
||||||
|
|
||||||
|
- Is the title clear and actionable?
|
||||||
|
- Is there a description? Check with `sf task describe <task-id> --show`
|
||||||
|
- Is the scope unambiguous?
|
||||||
|
|
||||||
|
**If the title is unclear**, update it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf update <task-id> --title "Clear, actionable title"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add a description** with context, scope, and acceptance criteria:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf task describe <task-id> --content "Description with:
|
||||||
|
- What needs to be done
|
||||||
|
- Why it matters
|
||||||
|
- Acceptance criteria
|
||||||
|
- Any relevant context"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use your best judgment** to interpret tasks and make reasonable decisions about scope, grouping, and priority. You have context about the codebase, project patterns, and typical development practices—leverage this knowledge rather than deferring to the user for routine decisions.
|
||||||
|
|
||||||
|
**Only ask the user for clarity when absolutely necessary:**
|
||||||
|
|
||||||
|
- The task is fundamentally ambiguous (multiple mutually exclusive interpretations)
|
||||||
|
- Critical business logic or user-facing behavior that could go wrong in meaningful ways
|
||||||
|
- External dependencies or integrations you cannot verify
|
||||||
|
|
||||||
|
**Do NOT ask about:**
|
||||||
|
|
||||||
|
- Implementation details you can reasonably infer
|
||||||
|
- Priority or grouping decisions—use your judgment
|
||||||
|
- Standard development practices (testing, code style, etc.)
|
||||||
|
- Tasks where a reasonable interpretation exists
|
||||||
|
|
||||||
|
### Step 3: Create Draft Plans
|
||||||
|
|
||||||
|
Group related tasks into plans using your best judgment. Plans start as drafts (tasks won't be dispatched until activated).
|
||||||
|
|
||||||
|
**Grouping guidance:**
|
||||||
|
|
||||||
|
- Group tasks that share a common theme, feature area, or goal
|
||||||
|
- Consider technical dependencies when grouping (tasks that touch the same files/modules)
|
||||||
|
- Separate unrelated work into distinct plans for parallel execution
|
||||||
|
- Don't over-group—if tasks are truly independent, separate plans enable better parallelism
|
||||||
|
- Don't under-group—related tasks benefit from shared context and coordinated execution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan create --title "Plan Name"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan create --title "Authentication Improvements"
|
||||||
|
# Output: Created plan el-abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Add Tasks to Plans
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan add-task <plan-id> <task-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan add-task el-abc123 el-task1
|
||||||
|
sf plan add-task el-abc123 el-task2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Set Dependencies Between Tasks
|
||||||
|
|
||||||
|
Use `blocks` dependency when one task must complete before another can start.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf dependency add <blocked-id> <blocker-id> --type blocks
|
||||||
|
```
|
||||||
|
|
||||||
|
**Semantics:** The first ID is blocked BY the second ID. The blocker must complete first.
|
||||||
|
|
||||||
|
**Example:** Task 2 can't start until Task 1 completes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf dependency add el-task2 el-task1 --type blocks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Update Priorities
|
||||||
|
|
||||||
|
Set priorities based on your assessment of impact, urgency, and dependencies. Use your judgment—you don't need user confirmation for routine prioritization.
|
||||||
|
|
||||||
|
**Priority guidance:**
|
||||||
|
|
||||||
|
- **Critical (1):** Blocking issues, security vulnerabilities, production bugs
|
||||||
|
- **High (2):** Important features with deadlines, significant user impact
|
||||||
|
- **Medium (3):** Standard feature work, most tasks default here
|
||||||
|
- **Low (4):** Nice-to-haves, minor improvements, tech debt
|
||||||
|
- **Minimal (5):** Backlog cleanup, documentation, exploratory work
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf update <task-id> --priority <1-5>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Value | Level |
|
||||||
|
| ----- | -------- |
|
||||||
|
| 1 | Critical |
|
||||||
|
| 2 | High |
|
||||||
|
| 3 | Medium |
|
||||||
|
| 4 | Low |
|
||||||
|
| 5 | Minimal |
|
||||||
|
|
||||||
|
### Step 7: Activate Plans
|
||||||
|
|
||||||
|
Once tasks are organized with dependencies set, activate plans to enable dispatch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan activate <plan-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Set Task Status to Open
|
||||||
|
|
||||||
|
Move tasks from backlog to open so they become ready for work.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf update <id> --status open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other Actions
|
||||||
|
|
||||||
|
**Close obsolete tasks:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf task close <id> --reason "Won't do: <reason>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Defer tasks:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf task defer <id> --until <date>
|
||||||
|
```
|
||||||
|
|
||||||
|
**View existing plans:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan list
|
||||||
|
```
|
||||||
|
|
||||||
|
**View tasks in a plan:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan tasks <plan-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- **Use your best judgment** for grouping, prioritization, and task interpretation—don't defer routine decisions to the user
|
||||||
|
- **Only escalate to the user** when ambiguity is fundamental and could lead to wasted work (mutually exclusive interpretations, critical business decisions)
|
||||||
|
- Make reasonable inferences about implementation details, scope, and priority based on codebase context
|
||||||
|
- Create plans before setting dependencies to avoid dispatch race conditions
|
||||||
|
- Always activate plans after dependencies are set
|
||||||
|
- Focus on oldest backlog items first (sorted by creation date)
|
||||||
|
- Every task should have a clear title and description before activation
|
||||||
|
- When uncertain about a minor detail, make a reasonable choice and document it in the task description—workers can ask if needed
|
||||||
+9
-4
@@ -17,6 +17,7 @@ __pycache__/
|
|||||||
*.so
|
*.so
|
||||||
.python-version
|
.python-version
|
||||||
.venv/
|
.venv/
|
||||||
|
.venv-test/
|
||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
@@ -48,8 +49,12 @@ apps/web/dist/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
/.stoneforge/.worktrees/
|
||||||
# Local runtime state
|
# Pi / agent cache
|
||||||
.atl/
|
|
||||||
.pi/
|
.pi/
|
||||||
swap-pane
|
.atl/
|
||||||
|
.sisyphus/
|
||||||
|
.pi-lens/
|
||||||
|
minerv3/
|
||||||
|
.cache/
|
||||||
|
openspec-audit-report.md
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# .opencode (index)
|
||||||
|
dir: .opencode
|
||||||
|
|
||||||
|
## role
|
||||||
|
Configuration 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
|
||||||
|
Configuration directory for the OpenCode AI coding assistant tool
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Tool-specific configuration storage using standard dot-directory convention with likely JSON/YAML config files and optional workspace state/logs
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# .opencode/commands (index)
|
||||||
|
dir: .opencode/commands
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines structured workflow commands and AI assistant stances for an experimental OpenSpec-based development system that guides changes through exploration, proposal, implementation, and archival phases.
|
||||||
|
## 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 structured workflow commands and AI assistant stances for an experimental OpenSpec-based development system that guides changes through exploration, proposal, implementation, and archival phases.
|
||||||
|
## files
|
||||||
|
- opsx-apply.md | Defines an experimental workflow skill for implementing tasks from an OpenSpec change through a structured, interactive process with CLI integration and progress tracking. | dep: openspec CLI, AskUserQuestion tool, filesystem (for reading context files)
|
||||||
|
- opsx-archive.md | Defines a workflow for archiving completed changes in an experimental openspec-based development system | dep: openspec CLI, AskUserQuestion tool, Task tool, Skill tool, filesystem (mkdir, mv), JSON parsing
|
||||||
|
- opsx-explore.md | Defines the "explore mode" stance for an AI assistant - a thinking/discovery mode for investigating problems and clarifying requirements without implementing code | dep: OpenSpec system
|
||||||
|
- opsx-propose.md | Defines a workflow for proposing new changes in the openspec system by creating a change directory and generating all required artifacts (proposal.md, design.md, tasks.md) in dependency order | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool, JSON parsing
|
||||||
|
## arch
|
||||||
|
Markdown-based declarative workflow definitions using a command pattern where each `.md` file specifies a distinct operational stance or phase (explore/propose/apply/archive) with embedded metadata, structured steps, and CLI integration points for progressive change lifecycle management.
|
||||||
|
## tags
|
||||||
|
opsx, defines, workflow, openspec, openspec cli, askuserquestion tool, explore, experimental
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# .opencode/skills (index)
|
||||||
|
dir: .opencode/skills
|
||||||
|
|
||||||
|
## role
|
||||||
|
Contains reusable skill modules that provide specific capabilities or integrations for the OpenCode agent framework.
|
||||||
|
## parent
|
||||||
|
index: .opencode/.pi-map.index.md
|
||||||
|
map: .opencode/.pi-map.md
|
||||||
|
## children
|
||||||
|
- .opencode/skills/openspec-apply-change
|
||||||
|
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-apply-change/.pi-map.md
|
||||||
|
- .opencode/skills/openspec-archive-change
|
||||||
|
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-archive-change/.pi-map.md
|
||||||
|
- .opencode/skills/openspec-explore
|
||||||
|
index: .opencode/skills/openspec-explore/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-explore/.pi-map.md
|
||||||
|
- .opencode/skills/openspec-propose
|
||||||
|
index: .opencode/skills/openspec-propose/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-propose/.pi-map.md
|
||||||
|
## files
|
||||||
|
## links
|
||||||
|
index: .opencode/skills/.pi-map.index.md
|
||||||
|
map: .opencode/skills/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# .opencode/skills
|
||||||
|
dir: .opencode/skills
|
||||||
|
|
||||||
|
index: .opencode/skills/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Contains reusable skill modules that provide specific capabilities or integrations for the OpenCode agent framework.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Modular plugin-based architecture where each skill is a self-contained unit implementing standardized interfaces for dynamic discovery and loading by the agent core.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-apply-change (index)
|
||||||
|
dir: .opencode/skills/openspec-apply-change
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides a spec-driven AI skill that implements tasks from OpenSpec changes by applying structured modifications to a codebase.
|
||||||
|
## 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 a spec-driven AI skill that implements tasks from OpenSpec changes by applying structured modifications to a codebase.
|
||||||
|
## files
|
||||||
|
- SKILL.md | Defines an AI assistant skill for implementing tasks from an OpenSpec change using a spec-driven workflow | dep: openspec CLI, AskUserQuestion tool, filesystem access
|
||||||
|
## arch
|
||||||
|
Skill-based modular architecture using markdown-defined capabilities with a workflow-driven pattern that separates specification parsing from implementation execution.
|
||||||
|
## tags
|
||||||
|
skill, defines, assistant, implementing, tasks, openspec, change, spec
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-archive-change (index)
|
||||||
|
dir: .opencode/skills/openspec-archive-change
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides a skill definition 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
|
||||||
|
Provides a skill definition 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, file system (mkdir, mv, read), JSON parsing
|
||||||
|
## arch
|
||||||
|
Single-file skill definition using markdown-based skill documentation pattern with CLI tool integration.
|
||||||
|
## tags
|
||||||
|
skill, defines, archiving, completed, changes, experimental, workflow, openspec
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-explore (index)
|
||||||
|
dir: .opencode/skills/openspec-explore
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines a conversational AI skill that acts as a thinking partner for exploring ideas, investigating problems, and clarifying requirements without implementing code.
|
||||||
|
## parent
|
||||||
|
index: .opencode/skills/.pi-map.index.md
|
||||||
|
map: .opencode/skills/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- SKILL.md
|
||||||
|
## links
|
||||||
|
index: .opencode/skills/openspec-explore/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-explore/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-explore
|
||||||
|
dir: .opencode/skills/openspec-explore
|
||||||
|
|
||||||
|
index: .opencode/skills/openspec-explore/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines a conversational AI skill that acts as a thinking partner for exploring ideas, investigating problems, and clarifying requirements without implementing code.
|
||||||
|
## files
|
||||||
|
- SKILL.md | Defines a conversational AI skill/persona for "explore mode" - a thinking partner for exploring ideas, investigating problems, and clarifying requirements without implementing code. | dep: openspec CLI
|
||||||
|
## arch
|
||||||
|
Markdown-based skill definition using persona/role-prompting pattern with structured behavioral guidelines for non-implementation exploratory conversations.
|
||||||
|
## tags
|
||||||
|
skill, defines, conversational, persona, explore, mode, thinking, partner
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-propose (index)
|
||||||
|
dir: .opencode/skills/openspec-propose
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines a structured workflow skill for proposing new changes using the openspec CLI, generating proposal, design, and task artifacts in dependency order.
|
||||||
|
## parent
|
||||||
|
index: .opencode/skills/.pi-map.index.md
|
||||||
|
map: .opencode/skills/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- SKILL.md
|
||||||
|
## links
|
||||||
|
index: .opencode/skills/openspec-propose/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-propose/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-propose
|
||||||
|
dir: .opencode/skills/openspec-propose
|
||||||
|
|
||||||
|
index: .opencode/skills/openspec-propose/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines a structured workflow skill for proposing new changes using the openspec CLI, generating proposal, design, and task artifacts in dependency order.
|
||||||
|
## files
|
||||||
|
- SKILL.md | Defines a structured workflow for proposing new changes using the openspec CLI, generating proposal, design, and task artifacts in dependency order. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool
|
||||||
|
## arch
|
||||||
|
Template-based skill definition using markdown documentation with structured workflow patterns for CLI-driven artifact generation and dependency-ordered execution.
|
||||||
|
## tags
|
||||||
|
skill, defines, structured, workflow, proposing, new, changes, openspec
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# . (index)
|
||||||
|
dir: .
|
||||||
|
|
||||||
|
## Project Map Protocol
|
||||||
|
|
||||||
|
1. Read this protocol and the root `.pi-map.index.md` first.
|
||||||
|
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
|
||||||
|
3. Load indexes before rich maps during task-start navigation.
|
||||||
|
4. Read the local rich map and actual source before editing.
|
||||||
|
5. Treat non-empty `## dirty` sections in either artifact as stale.
|
||||||
|
6. If source and generated artifacts disagree, trust source.
|
||||||
|
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||||
|
8. After editing source, run `project_map_patch` for each changed file.
|
||||||
|
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
|
||||||
|
|
||||||
|
Trust boundary: index routes, map orients, source decides.
|
||||||
|
|
||||||
|
## role
|
||||||
|
Infrastructure and deployment configuration package for a self-hosted project management platform with multi-service Docker orchestration, SSO integration, and development tooling.
|
||||||
|
## parent
|
||||||
|
-
|
||||||
|
## children
|
||||||
|
- .atl
|
||||||
|
index: .atl/.pi-map.index.md
|
||||||
|
map: .atl/.pi-map.md
|
||||||
|
- .claude
|
||||||
|
index: .claude/.pi-map.index.md
|
||||||
|
map: .claude/.pi-map.md
|
||||||
|
- .opencode
|
||||||
|
index: .opencode/.pi-map.index.md
|
||||||
|
map: .opencode/.pi-map.md
|
||||||
|
- .pi
|
||||||
|
index: .pi/.pi-map.index.md
|
||||||
|
map: .pi/.pi-map.md
|
||||||
|
- .sisyphus
|
||||||
|
index: .sisyphus/.pi-map.index.md
|
||||||
|
map: .sisyphus/.pi-map.md
|
||||||
|
- .stoneforge
|
||||||
|
index: .stoneforge/.pi-map.index.md
|
||||||
|
map: .stoneforge/.pi-map.md
|
||||||
|
- apps
|
||||||
|
index: apps/.pi-map.index.md
|
||||||
|
map: apps/.pi-map.md
|
||||||
|
- docs
|
||||||
|
index: docs/.pi-map.index.md
|
||||||
|
map: docs/.pi-map.md
|
||||||
|
- e2e
|
||||||
|
index: e2e/.pi-map.index.md
|
||||||
|
map: e2e/.pi-map.md
|
||||||
|
- minerv3
|
||||||
|
index: minerv3/.pi-map.index.md
|
||||||
|
map: minerv3/.pi-map.md
|
||||||
|
- openspec
|
||||||
|
index: openspec/.pi-map.index.md
|
||||||
|
map: openspec/.pi-map.md
|
||||||
|
- scripts
|
||||||
|
index: scripts/.pi-map.index.md
|
||||||
|
map: scripts/.pi-map.md
|
||||||
|
- tool-images
|
||||||
|
index: tool-images/.pi-map.index.md
|
||||||
|
map: tool-images/.pi-map.md
|
||||||
|
## files
|
||||||
|
- .env.example
|
||||||
|
- .gitignore
|
||||||
|
- AGENTS.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
- Makefile
|
||||||
|
- README.md
|
||||||
|
- docker-compose.traefik.yml
|
||||||
|
- docker-compose.yml
|
||||||
|
- progress.md
|
||||||
|
- swap-pane
|
||||||
|
## links
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
map: ./.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# .
|
||||||
|
dir: .
|
||||||
|
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
|
||||||
|
## Project Map Protocol
|
||||||
|
|
||||||
|
1. Read this protocol and the root `.pi-map.index.md` first.
|
||||||
|
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
|
||||||
|
3. Load indexes before rich maps during task-start navigation.
|
||||||
|
4. Read the local rich map and actual source before editing.
|
||||||
|
5. Treat non-empty `## dirty` sections in either artifact as stale.
|
||||||
|
6. If source and generated artifacts disagree, trust source.
|
||||||
|
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||||
|
8. After editing source, run `project_map_patch` for each changed file.
|
||||||
|
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
|
||||||
|
|
||||||
|
Trust boundary: index routes, map orients, source decides.
|
||||||
|
|
||||||
|
## role
|
||||||
|
Infrastructure and deployment configuration package for a self-hosted project management platform with multi-service Docker orchestration, SSO integration, and development tooling.
|
||||||
|
## files
|
||||||
|
- .env.example | Provides a template of environment variables for configuring a Headquarter application with PostgreSQL, Redis, Authentik SSO, and Docker/Traefik deployment
|
||||||
|
- .gitignore | Specifies files and directories for Git to ignore across a multi-language project with Python, Node, and custom tooling | dep: Git
|
||||||
|
- AGENTS.md | Defines operational rules, workflows, and constraints for AI agents working within an OpenSpec-driven software development project. | dep: OpenSpec, superpowers, git, docker compose, conventional commits
|
||||||
|
- CHANGELOG.md | Documents version history and notable changes for a Git-based project management web application
|
||||||
|
- Makefile | Provides standard development commands for containerized web application lifecycle management via Docker Compose | dep: docker compose, alembic, pytest, ruff, mypy, playwright, npm, postgres, redis
|
||||||
|
- README.md | A self-hosted platform for managing projects, git repositories, and development tools with OAuth2 authentication. | dep: FastAPI, SQLAlchemy, Pydantic, Alembic, python-jose, React, TypeScript, Vite, React Router, Docker, PostgreSQL, Traefik, Authentik, Git
|
||||||
|
- docker-compose.traefik.yml | Deploys a multi-service web application (frontend, API, PostgreSQL, Redis) behind an existing Traefik reverse proxy with TLS termination and environment-configurable domains. | dep: docker, traefik, postgres, redis, authentik, docker-compose
|
||||||
|
- docker-compose.yml | Defines a multi-service Docker Compose stack with PostgreSQL, Redis, API backend, and web frontend services for a "headquarter" application | dep: Docker, PostgreSQL, Redis, Vite, asyncpg, nginx
|
||||||
|
- progress.md | Tracks completed and remaining tasks for a backend-frontend code refactoring project organized in 7 phases
|
||||||
|
- swap-pane | Empty file with no functionality
|
||||||
|
## arch
|
||||||
|
Containerized microservices architecture using Docker Compose with separate PostgreSQL/Redis data layer, API backend, and web frontend services, fronted by Traefik reverse proxy with TLS termination, environment-driven configuration, and Makefile-based lifecycle management.
|
||||||
|
## tags
|
||||||
|
docker, redis, git, application, postgresql, compose, traefik, project
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_1da2608b1ffergOzow3NQt1mGr",
|
||||||
|
"updatedAt": "2026-05-15T23:50:42.832Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-05-15T23:50:42.832Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
262629
|
||||||
|
1779624255076
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Runtime data
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
daemon-state.json
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# .stoneforge (index)
|
||||||
|
dir: .stoneforge
|
||||||
|
|
||||||
|
## role
|
||||||
|
Hidden system directory storing runtime state, configuration, and version-control metadata for the Stoneforge application.
|
||||||
|
## parent
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
map: ./.pi-map.md
|
||||||
|
## children
|
||||||
|
- .stoneforge/sync
|
||||||
|
index: .stoneforge/sync/.pi-map.index.md
|
||||||
|
map: .stoneforge/sync/.pi-map.md
|
||||||
|
## files
|
||||||
|
- .dashboard-opened
|
||||||
|
- .gitignore
|
||||||
|
- config.yaml
|
||||||
|
## links
|
||||||
|
index: .stoneforge/.pi-map.index.md
|
||||||
|
map: .stoneforge/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# .stoneforge
|
||||||
|
dir: .stoneforge
|
||||||
|
|
||||||
|
index: .stoneforge/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Hidden system directory storing runtime state, configuration, and version-control metadata for the Stoneforge application.
|
||||||
|
## files
|
||||||
|
- .dashboard-opened | Stores timestamp and identifier data for tracking when a dashboard was opened
|
||||||
|
- .gitignore | Specifies files and patterns for Git to ignore in version control
|
||||||
|
- config.yaml | Configuration file for the Stoneforge application defining database, sync, playbook, identity, merge, workflow, and agent settings.
|
||||||
|
## arch
|
||||||
|
Simple flat-file storage with YAML-based configuration, dotfile-based state tracking, and standard Git ignore patterns.
|
||||||
|
## tags
|
||||||
|
config, stores, timestamp, identifier, data, tracking, dashboard, was
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Stoneforge Configuration
|
||||||
|
|
||||||
|
database: stoneforge.db
|
||||||
|
sync:
|
||||||
|
auto_export: true
|
||||||
|
elements_file: elements.jsonl
|
||||||
|
dependencies_file: dependencies.jsonl
|
||||||
|
playbooks:
|
||||||
|
paths:
|
||||||
|
- playbooks
|
||||||
|
identity:
|
||||||
|
mode: soft
|
||||||
|
merge:
|
||||||
|
auto_merge: true
|
||||||
|
target_branch: null
|
||||||
|
require_approval: false
|
||||||
|
workflow:
|
||||||
|
preset: auto
|
||||||
|
agents:
|
||||||
|
permission_model: unrestricted
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# .stoneforge/sync (index)
|
||||||
|
dir: .stoneforge/sync
|
||||||
|
|
||||||
|
## role
|
||||||
|
Distributed task coordination and ephemeral worker agent management for a multi-agent execution 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
|
||||||
|
Distributed task coordination and ephemeral worker agent management for a multi-agent execution system.
|
||||||
|
## files
|
||||||
|
- dependencies.jsonl | Stores a sequence of dependency relationships between entities in JSON Lines format, tracking parent-child, blocking, and reply relationships with timestamps and creators.
|
||||||
|
- elements.jsonl | Stores JSONL records of ephemeral worker agents with their session history, worktree assignments, and lifecycle metadata for a distributed task execution system.
|
||||||
|
## arch
|
||||||
|
Event-sourced JSONL append-only logs with entity-relationship graph tracking, session-based worker lifecycle management, and decentralized worktree assignment.
|
||||||
|
## tags
|
||||||
|
stores, relationships, dependencies, elements, sequence, dependency, entities, json
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{"blockedId":"el-1of","blockerId":"el-258","type":"parent-child","createdAt":"2026-05-24T09:44:58.759Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5fe","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:40.892Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1nj","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.010Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1bn","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.127Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4hr","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.244Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-62c","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.372Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5z8","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.490Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.607Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5j5","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.726Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-2xl","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.844Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.959Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-107","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.074Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-32e","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.195Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-3ou","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.311Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-14w","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.425Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1ou","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.541Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1nj","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.651Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1bn","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.761Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4hr","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.868Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-62c","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:42.979Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-62c","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:43.092Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5z8","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:43.205Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5z8","blockerId":"el-4hr","type":"blocks","createdAt":"2026-05-24T12:44:43.313Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:43.422Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-4hr","type":"blocks","createdAt":"2026-05-24T12:44:43.529Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-62c","type":"blocks","createdAt":"2026-05-24T12:44:43.647Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5j5","blockerId":"el-1t7","type":"blocks","createdAt":"2026-05-24T12:44:43.758Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-2xl","blockerId":"el-1t7","type":"blocks","createdAt":"2026-05-24T12:44:43.876Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:43.987Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:44.096Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-62c","type":"blocks","createdAt":"2026-05-24T12:44:44.208Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-107","blockerId":"el-5z8","type":"blocks","createdAt":"2026-05-24T12:44:44.319Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-32e","blockerId":"el-5j5","type":"blocks","createdAt":"2026-05-24T12:44:44.429Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-32e","blockerId":"el-2xl","type":"blocks","createdAt":"2026-05-24T12:44:44.539Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-3ou","blockerId":"el-4bc","type":"blocks","createdAt":"2026-05-24T12:44:44.650Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-3ou","blockerId":"el-107","type":"blocks","createdAt":"2026-05-24T12:44:44.761Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-14w","blockerId":"el-32e","type":"blocks","createdAt":"2026-05-24T12:44:44.873Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1ou","blockerId":"el-3ou","type":"blocks","createdAt":"2026-05-24T12:44:44.987Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1ou","blockerId":"el-14w","type":"blocks","createdAt":"2026-05-24T12:44:45.107Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-375","blockerId":"el-26p","type":"replies-to","createdAt":"2026-05-24T13:21:42.486Z","createdBy":"el-2i1s"}
|
||||||
|
{"blockedId":"el-3n4","blockerId":"el-31p","type":"replies-to","createdAt":"2026-05-24T13:21:46.044Z","createdBy":"el-13ju"}
|
||||||
|
{"blockedId":"el-3jer","blockerId":"el-1xx","type":"replies-to","createdAt":"2026-05-24T13:24:47.580Z","createdBy":"el-4350"}
|
||||||
|
{"blockedId":"el-1afv","blockerId":"el-1ozw","type":"replies-to","createdAt":"2026-05-24T13:32:42.658Z","createdBy":"el-51a8"}
|
||||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
OpenSpec is the source of truth. Superpowers is the default workflow. Keep changes small, scoped, and verified.
|
OpenSpec is the source of truth. Superpowers is the default workflow. Keep changes small, scoped, and verified.
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
All agent output, code comments, commit messages, documentation, and artifacts must be in **English** unless the user explicitly requests another language.
|
||||||
|
|
||||||
## Priority order
|
## Priority order
|
||||||
|
|
||||||
1. Current user instruction
|
1. Current user instruction
|
||||||
@@ -71,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.
|
||||||
|
|
||||||
@@ -87,6 +92,31 @@ Do not claim completion without verification evidence.
|
|||||||
|
|
||||||
## Git workflow
|
## Git workflow
|
||||||
|
|
||||||
|
### Branching strategy
|
||||||
|
|
||||||
|
For every spec change or new functionality:
|
||||||
|
|
||||||
|
1. Create a new branch from `dev` with a proper prefix:
|
||||||
|
- `feat/` for new features (e.g., `feat/tool-workshop`)
|
||||||
|
- `fix/` for bug fixes (e.g., `fix/terminal-tty`)
|
||||||
|
- `refactor/` for refactors (e.g., `refactor/api-cleanup`)
|
||||||
|
- `docs/` for documentation (e.g., `docs/api-guide`)
|
||||||
|
- `chore/` for maintenance (e.g., `chore/update-deps`)
|
||||||
|
2. Branch name should reference the OpenSpec change name when applicable.
|
||||||
|
3. Do not commit directly to `main` or `dev`.
|
||||||
|
|
||||||
|
### Completion and merge
|
||||||
|
|
||||||
|
When implementation is complete and verified:
|
||||||
|
|
||||||
|
1. Ensure all tests pass and quality gates are met.
|
||||||
|
2. Stage all changes with `git add -A`.
|
||||||
|
3. Create a commit with a proper conventional commit message (see below).
|
||||||
|
4. Switch to `dev`: `git checkout dev`.
|
||||||
|
5. Merge the feature branch: `git merge --no-ff <branch-name>`.
|
||||||
|
6. Push to remote: `git push origin dev`.
|
||||||
|
7. Delete the local feature branch if desired: `git branch -d <branch-name>`.
|
||||||
|
|
||||||
### Auto-commit on spec completion
|
### Auto-commit on spec completion
|
||||||
|
|
||||||
When an OpenSpec change is fully implemented and all tasks are complete:
|
When an OpenSpec change is fully implemented and all tasks are complete:
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **User Settings** - Theme selection, git identity, and preference management
|
- **User Settings** - Theme selection, git identity, and preference management
|
||||||
- **SSH Key Management** - Ed25519 key generation with secure storage
|
- **SSH Key Management** - Ed25519 key generation with secure storage
|
||||||
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
||||||
- **Config Profiles** - User-owned profile CRUD with includes, mounts, path validation, cycle detection, and default profile selection
|
|
||||||
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# apps (index)
|
||||||
|
dir: apps
|
||||||
|
|
||||||
|
## role
|
||||||
|
Contains the main application entry points and executable modules for the project.
|
||||||
|
## parent
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
map: ./.pi-map.md
|
||||||
|
## children
|
||||||
|
- apps/api
|
||||||
|
index: apps/api/.pi-map.index.md
|
||||||
|
map: apps/api/.pi-map.md
|
||||||
|
- apps/web
|
||||||
|
index: apps/web/.pi-map.index.md
|
||||||
|
map: apps/web/.pi-map.md
|
||||||
|
## files
|
||||||
|
## links
|
||||||
|
index: apps/.pi-map.index.md
|
||||||
|
map: apps/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# apps
|
||||||
|
dir: apps
|
||||||
|
|
||||||
|
index: apps/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Contains the main application entry points and executable modules for the project.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Modular application structure with separate deployable units, likely following a microservices or monorepo pattern with distinct apps sharing common libraries.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Alembic cache
|
||||||
|
alembic/versions/__pycache__/
|
||||||
|
|
||||||
|
# Pi lens cache
|
||||||
|
.pi-lens/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Scripts not needed in container
|
||||||
|
scripts/
|
||||||
+568
@@ -0,0 +1,568 @@
|
|||||||
|
{
|
||||||
|
"version": "v2",
|
||||||
|
"timestamp": 1779889907001,
|
||||||
|
"ruleHash": "fd9b2b15f2ac8993",
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"id": "bare-except",
|
||||||
|
"name": "Bare Except Clause",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Bare 'except:' clause — catches SystemExit, KeyboardInterrupt",
|
||||||
|
"query": " (except_clause\n \"except\") @CLAUSE",
|
||||||
|
"metavars": [
|
||||||
|
"CLAUSE"
|
||||||
|
],
|
||||||
|
"post_filter": "bare_except_only",
|
||||||
|
"defect_class": "silent-error",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/bare-except.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eval-exec",
|
||||||
|
"name": "Eval/Exec Usage",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "{{FUNC}}() detected — security risk, code injection vulnerability",
|
||||||
|
"query": " (call\n function: (identifier) @FUNC\n (#match? @FUNC \"^(eval|exec)$\")\n arguments: (argument_list) @ARGS)",
|
||||||
|
"metavars": [
|
||||||
|
"FUNC",
|
||||||
|
"ARGS"
|
||||||
|
],
|
||||||
|
"defect_class": "injection",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/eval-exec.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "exit-signature-check",
|
||||||
|
"name": "__exit__ Missing Parameters",
|
||||||
|
"severity": "error",
|
||||||
|
"language": "python",
|
||||||
|
"message": "__exit__ should accept type, value, and traceback arguments",
|
||||||
|
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__exit__\")\n parameters: (parameters\n (_) @SELF\n . (_) @PARAM1?\n . (_) @PARAM2?\n . (_) @PARAM3?))",
|
||||||
|
"metavars": [
|
||||||
|
"NAME",
|
||||||
|
"SELF",
|
||||||
|
"PARAM1",
|
||||||
|
"PARAM2",
|
||||||
|
"PARAM3"
|
||||||
|
],
|
||||||
|
"post_filter": "exit_params_insufficient",
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/exit-signature-check.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "in-operator-unsupported",
|
||||||
|
"name": "In and Not In Operators Should Be Used on Valid Objects",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "'in' operator used on object that may not support containment",
|
||||||
|
"query": " (comparison_operator\n (identifier) @OBJ\n \"in\"\n (identifier) @TARGET)\n (comparison_operator\n (identifier) @OBJ\n \"not\"\n \"in\"\n (identifier) @TARGET)",
|
||||||
|
"metavars": [
|
||||||
|
"OBJ",
|
||||||
|
"TARGET"
|
||||||
|
],
|
||||||
|
"post_filter": "check_in_operator_types",
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "warning",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/in-operator-unsupported.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "is-vs-equals",
|
||||||
|
"name": "Is vs Equals for Literals",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Using 'is' with literal — use '==' for value comparison",
|
||||||
|
"query": " (comparison_operator\n (identifier)\n (\"is\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is\")\n (integer) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (integer) @LITERAL)",
|
||||||
|
"metavars": [
|
||||||
|
"LITERAL"
|
||||||
|
],
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/is-vs-equals.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iter-return-iterator",
|
||||||
|
"name": "__iter__ Should Return Iterator",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "__iter__ should return an iterator (object with __next__ method)",
|
||||||
|
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__iter__\")\n body: (block\n (return_statement) @RETURN))",
|
||||||
|
"metavars": [
|
||||||
|
"NAME",
|
||||||
|
"RETURN"
|
||||||
|
],
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/iter-return-iterator.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mutable-default-arg",
|
||||||
|
"name": "Mutable Default Argument",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Mutable default argument — list/dict/set as default value",
|
||||||
|
"query": " (function_definition\n (parameters\n (default_parameter\n (identifier) @PARAM\n [(list) (dictionary) (set)] @MUTABLE)))",
|
||||||
|
"metavars": [
|
||||||
|
"PARAM",
|
||||||
|
"MUTABLE"
|
||||||
|
],
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/mutable-default-arg.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no-super-torchscript",
|
||||||
|
"name": "super Should Not Be Used in TorchScript Methods",
|
||||||
|
"severity": "error",
|
||||||
|
"language": "python",
|
||||||
|
"message": "super() calls should not be used in TorchScript methods",
|
||||||
|
"query": " (function_definition\n (decorator\n (call\n function: (identifier) @DEC (#match? @DEC \"^(torch\\.jit\\.script|jit\\.script)$\")))\n body: (block\n (call\n function: (identifier) @FUNC (#eq? @FUNC \"super\")) @CALL))",
|
||||||
|
"metavars": [
|
||||||
|
"DEC",
|
||||||
|
"FUNC",
|
||||||
|
"CALL"
|
||||||
|
],
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/no-super-torchscript.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notimplemented-boolean-context",
|
||||||
|
"name": "NotImplemented in Boolean Context",
|
||||||
|
"severity": "error",
|
||||||
|
"language": "python",
|
||||||
|
"message": "NotImplemented should not be used in boolean contexts",
|
||||||
|
"query": " (if_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (while_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (binary_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\")\n (\"and\" | \"or\"))\n (boolean_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (unary_operator\n operator: (\"not\")\n argument: (identifier) @COND (#eq? @COND \"NotImplemented\"))",
|
||||||
|
"metavars": [
|
||||||
|
"COND"
|
||||||
|
],
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-assert-production",
|
||||||
|
"name": "Assert in Production Code",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "assert statement stripped by Python -O flag — use explicit checks with exceptions in production code",
|
||||||
|
"query": " (assert_statement) @ASSERT",
|
||||||
|
"metavars": [
|
||||||
|
"ASSERT"
|
||||||
|
],
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "warning",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-assert-production.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-command-injection",
|
||||||
|
"name": "Command Injection Sink",
|
||||||
|
"severity": "error",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Potential command injection sink — avoid shell execution with dynamic input",
|
||||||
|
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"os\")\n (#match? @FN \"^(system|popen)$\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true)))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
|
||||||
|
"metavars": [
|
||||||
|
"MOD",
|
||||||
|
"FN",
|
||||||
|
"ARGS",
|
||||||
|
"KW"
|
||||||
|
],
|
||||||
|
"post_filter": "py_command_injection_sink",
|
||||||
|
"defect_class": "injection",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-command-injection.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-cross-language-method",
|
||||||
|
"name": "Cross-Language Method Leakage",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "'{METHOD}' is not a Python method — likely a {LANG} idiom leaking in",
|
||||||
|
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @METHOD)\n (#match? @METHOD \"^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$\"))",
|
||||||
|
"metavars": [
|
||||||
|
"OBJ",
|
||||||
|
"METHOD"
|
||||||
|
],
|
||||||
|
"post_filter": "match_captures",
|
||||||
|
"post_filter_params": {
|
||||||
|
"METHOD": "^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$"
|
||||||
|
},
|
||||||
|
"defect_class": "hallucination",
|
||||||
|
"inline_tier": "warning",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-debugger",
|
||||||
|
"name": "Debugger Statement",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Debugger call '{{FUNC}}' — remove before committing",
|
||||||
|
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"breakpoint\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n (#eq? @MOD \"pdb\")\n (#match? @FUNC \"^(set_trace|post_mortem|pm|run|runcall)$\"))",
|
||||||
|
"metavars": [
|
||||||
|
"FUNC",
|
||||||
|
"MOD"
|
||||||
|
],
|
||||||
|
"defect_class": "safety",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-debugger.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-empty-except",
|
||||||
|
"name": "Empty Except Block",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Except block only contains 'pass' — handle or re-raise the exception",
|
||||||
|
"query": " (try_statement\n (except_clause\n body: (block) @BODY))",
|
||||||
|
"metavars": [
|
||||||
|
"BODY"
|
||||||
|
],
|
||||||
|
"post_filter": "python_empty_except",
|
||||||
|
"defect_class": "silent-error",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-empty-except.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-hallucinated-import",
|
||||||
|
"name": "Hallucinated Import",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Hallucinated import — '{NAME}' does not exist in '{MODULE}'",
|
||||||
|
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n name: (dotted_name) @NAME)",
|
||||||
|
"metavars": [
|
||||||
|
"MODULE",
|
||||||
|
"NAME"
|
||||||
|
],
|
||||||
|
"post_filter": "match_captures",
|
||||||
|
"post_filter_params": {
|
||||||
|
"MODULE": "^(requests|flask|django|typing|collections|asyncio|json|unittest|pytest|urllib|sqlalchemy)$",
|
||||||
|
"NAME": "^(JSONResponse|HTMLResponse|RedirectResponse|StreamingResponse|Depends|Query|Path|Body|Header|Cookie|Form|File|UploadFile|FastAPI|APIRouter|HTTPException|BackgroundTasks|dataclass|fields|BaseModel|Field|validator|aiohttp|parse|stringify|fixture|TestCase|get|post|put|delete|Model|Session|Column|Integer|String)$"
|
||||||
|
},
|
||||||
|
"defect_class": "hallucination",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-hardcoded-secrets",
|
||||||
|
"name": "Hardcoded Secret",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Hardcoded {{VARNAME}} — use environment variables or a secrets manager",
|
||||||
|
"query": " (assignment\n left: (identifier) @VARNAME\n right: (string) @VALUE)",
|
||||||
|
"metavars": [
|
||||||
|
"VARNAME",
|
||||||
|
"VALUE"
|
||||||
|
],
|
||||||
|
"post_filter": "check_secret_pattern",
|
||||||
|
"defect_class": "secrets",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hardcoded-secrets.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-insecure-deserialization",
|
||||||
|
"name": "Insecure Deserialization",
|
||||||
|
"severity": "error",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Potential insecure deserialization sink — avoid unsafe loaders",
|
||||||
|
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list (_) @DATA)\n (#match? @MOD \"^(pickle|yaml)$\")\n (#match? @FN \"^(load|loads|unsafe_load)$\"))",
|
||||||
|
"metavars": [
|
||||||
|
"MOD",
|
||||||
|
"FN",
|
||||||
|
"DATA"
|
||||||
|
],
|
||||||
|
"post_filter": "py_insecure_deserialization_sink",
|
||||||
|
"defect_class": "injection",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-deserialization.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-insecure-random",
|
||||||
|
"name": "Insecure Randomness",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Insecure randomness source detected — use secrets or os.urandom for security-sensitive values",
|
||||||
|
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"random\")\n (#match? @FN \"^(random|randint|randrange|choice|choices)$\"))",
|
||||||
|
"metavars": [
|
||||||
|
"MOD",
|
||||||
|
"FN",
|
||||||
|
"ARGS"
|
||||||
|
],
|
||||||
|
"defect_class": "injection",
|
||||||
|
"inline_tier": "warning",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-random.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-mutable-class-attr",
|
||||||
|
"name": "Mutable Class Attribute",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Class attribute '{{VARNAME}}' is mutable — shared across all instances",
|
||||||
|
"query": " (class_definition\n body: (block\n (expression_statement\n (assignment\n left: (identifier) @VARNAME\n right: [\n (list) @VALUE\n (dictionary) @VALUE\n (set) @VALUE\n ]))))",
|
||||||
|
"metavars": [
|
||||||
|
"VARNAME",
|
||||||
|
"VALUE"
|
||||||
|
],
|
||||||
|
"post_filter": "not_in_function",
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-mutable-class-attr.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-path-traversal",
|
||||||
|
"name": "Path Traversal Risk",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Potential path traversal sink — sanitize and constrain file paths",
|
||||||
|
"query": " [\n (call\n function: (identifier) @FN\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n ]\n (#match? @FN \"^(open|read_text|read_bytes|write_text|write_bytes|remove|unlink|rmdir)$\")",
|
||||||
|
"metavars": [
|
||||||
|
"MOD",
|
||||||
|
"FN",
|
||||||
|
"PATH"
|
||||||
|
],
|
||||||
|
"post_filter": "py_path_traversal_sink",
|
||||||
|
"defect_class": "injection",
|
||||||
|
"inline_tier": "warning",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-path-traversal.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-print-statement",
|
||||||
|
"name": "Print Statement in Production",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "print() — remove debug output before committing",
|
||||||
|
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"print\")\n arguments: (argument_list) @ARGS)",
|
||||||
|
"metavars": [
|
||||||
|
"FUNC",
|
||||||
|
"ARGS"
|
||||||
|
],
|
||||||
|
"post_filter": "not_in_test_block",
|
||||||
|
"defect_class": "safety",
|
||||||
|
"inline_tier": "warning",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-print-statement.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-raise-string",
|
||||||
|
"name": "Raise String Instead of Exception",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "raise with string literal — Python 3 requires exception instances",
|
||||||
|
"query": " (raise_statement\n (string) @VALUE)",
|
||||||
|
"metavars": [
|
||||||
|
"VALUE"
|
||||||
|
],
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-raise-string.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-sleep-in-test",
|
||||||
|
"name": "time.sleep in Test",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "time.sleep() in test — use synchronisation primitives or polling helpers instead of fixed sleeps",
|
||||||
|
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n (#eq? @MOD \"time\")\n (#eq? @FN \"sleep\")) @CALL",
|
||||||
|
"metavars": [
|
||||||
|
"MOD",
|
||||||
|
"FN",
|
||||||
|
"CALL"
|
||||||
|
],
|
||||||
|
"defect_class": "async-misuse",
|
||||||
|
"inline_tier": "warning",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sleep-in-test.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-sql-injection",
|
||||||
|
"name": "SQL Injection Risk",
|
||||||
|
"severity": "error",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Potential SQL injection sink — use parameterized queries",
|
||||||
|
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(binary_operator) (identifier) (call)] @SQL\n (_)*))",
|
||||||
|
"metavars": [
|
||||||
|
"OBJ",
|
||||||
|
"FN",
|
||||||
|
"SQL"
|
||||||
|
],
|
||||||
|
"post_filter": "py_sql_injection_sink",
|
||||||
|
"defect_class": "injection",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-ssrf",
|
||||||
|
"name": "SSRF Risk",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Potential SSRF sink — validate/allowlist outbound URLs",
|
||||||
|
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (subscript) (call)] @URL)\n (#eq? @MOD \"requests\")\n (#match? @FN \"^(get|post|put|patch|delete|request|head|options)$\"))",
|
||||||
|
"metavars": [
|
||||||
|
"MOD",
|
||||||
|
"FN",
|
||||||
|
"URL"
|
||||||
|
],
|
||||||
|
"post_filter": "py_ssrf_sink",
|
||||||
|
"defect_class": "injection",
|
||||||
|
"inline_tier": "warning",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-ssrf.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-subprocess-shell",
|
||||||
|
"name": "subprocess with shell=True",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "subprocess called with shell=True — command injection risk if any argument is user-controlled",
|
||||||
|
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true) @VAL))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
|
||||||
|
"metavars": [
|
||||||
|
"MOD",
|
||||||
|
"FN",
|
||||||
|
"KW"
|
||||||
|
],
|
||||||
|
"defect_class": "injection",
|
||||||
|
"inline_tier": "warning",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-subprocess-shell.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-thread-global-write",
|
||||||
|
"name": "Threaded Shared State Risk",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Thread creation detected — ensure shared state mutations are synchronized",
|
||||||
|
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS)\n (#eq? @MOD \"threading\")\n (#eq? @FN \"Thread\")",
|
||||||
|
"metavars": [
|
||||||
|
"MOD",
|
||||||
|
"FN",
|
||||||
|
"ARGS"
|
||||||
|
],
|
||||||
|
"defect_class": "async-misuse",
|
||||||
|
"inline_tier": "warning",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-thread-global-write.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-unsafe-regex",
|
||||||
|
"name": "Unsafe Dynamic Regex",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "re.{{FUNC}}() with variable pattern — ReDoS risk if pattern is user-controlled",
|
||||||
|
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n arguments: (argument_list\n (identifier) @PATTERN)\n (#eq? @MOD \"re\")\n (#match? @FUNC \"^(compile|match|search|fullmatch|findall|finditer|sub|subn|split)$\"))",
|
||||||
|
"metavars": [
|
||||||
|
"MOD",
|
||||||
|
"FUNC",
|
||||||
|
"PATTERN"
|
||||||
|
],
|
||||||
|
"defect_class": "injection",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-unsafe-regex.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python-weak-hash",
|
||||||
|
"name": "Weak Hash Primitive",
|
||||||
|
"severity": "error",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Weak hash primitive detected (MD5/SHA1) — use SHA-256+ for security-sensitive contexts",
|
||||||
|
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"hashlib\")\n (#match? @FN \"^(md5|sha1)$\"))",
|
||||||
|
"metavars": [
|
||||||
|
"MOD",
|
||||||
|
"FN",
|
||||||
|
"ARGS"
|
||||||
|
],
|
||||||
|
"defect_class": "injection",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-weak-hash.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "return-in-generator",
|
||||||
|
"name": "Return with Value in Generator",
|
||||||
|
"severity": "error",
|
||||||
|
"language": "python",
|
||||||
|
"message": "'return' with a value should not be used in a generator function",
|
||||||
|
"query": " (function_definition\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN)) @FUNCTION",
|
||||||
|
"metavars": [
|
||||||
|
"FUNCTION",
|
||||||
|
"RETURN",
|
||||||
|
"RETURN_VAL"
|
||||||
|
],
|
||||||
|
"post_filter": "is_generator_with_valued_return",
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-generator.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "return-in-init",
|
||||||
|
"name": "Return Value in __init__",
|
||||||
|
"severity": "error",
|
||||||
|
"language": "python",
|
||||||
|
"message": "__init__ should not return a value — it must always return None",
|
||||||
|
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__init__\")\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN))",
|
||||||
|
"metavars": [
|
||||||
|
"NAME",
|
||||||
|
"RETURN",
|
||||||
|
"RETURN_VAL"
|
||||||
|
],
|
||||||
|
"post_filter": "has_return_value",
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-init.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "send-file-mimetype",
|
||||||
|
"name": "send_file Should Specify Mimetype or Download Name",
|
||||||
|
"severity": "error",
|
||||||
|
"language": "python",
|
||||||
|
"message": "send_file should specify 'mimetype' or 'download_name' when used with file-like objects",
|
||||||
|
"query": " (call\n function: (identifier) @FUNC (#eq? @FUNC \"send_file\")\n arguments: (argument_list\n (_) @FIRST_ARG\n (keyword_argument)? @KW))",
|
||||||
|
"metavars": [
|
||||||
|
"FUNC",
|
||||||
|
"FIRST_ARG",
|
||||||
|
"KW"
|
||||||
|
],
|
||||||
|
"post_filter": "missing_mimetype_and_download_name",
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/send-file-mimetype.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "unreachable-except",
|
||||||
|
"name": "Unreachable Except Clause",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Unreachable except clause — earlier except catches all",
|
||||||
|
"query": " (try_statement\n (except_clause\n \"except\") @GENERAL\n (except_clause\n \"except\"\n (identifier) @SPECIFIC))",
|
||||||
|
"metavars": [
|
||||||
|
"GENERAL",
|
||||||
|
"SPECIFIC"
|
||||||
|
],
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/unreachable-except.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wildcard-import",
|
||||||
|
"name": "Wildcard Import",
|
||||||
|
"severity": "warning",
|
||||||
|
"language": "python",
|
||||||
|
"message": "Wildcard import — pollutes namespace, hard to track origin",
|
||||||
|
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n (wildcard_import) @WILDCARD)",
|
||||||
|
"metavars": [
|
||||||
|
"MODULE",
|
||||||
|
"WILDCARD"
|
||||||
|
],
|
||||||
|
"defect_class": "safety",
|
||||||
|
"inline_tier": "warning",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/wildcard-import.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "yield-return-outside-function",
|
||||||
|
"name": "Yield/Return Outside Function",
|
||||||
|
"severity": "error",
|
||||||
|
"language": "python",
|
||||||
|
"message": "{{STATEMENT}} used outside function — syntax error",
|
||||||
|
"query": " (module\n (expression_statement\n (yield) @STATEMENT))\n (module\n (expression_statement\n (yield_expression) @STATEMENT))\n (module\n (return_statement) @STATEMENT)",
|
||||||
|
"metavars": [
|
||||||
|
"STATEMENT"
|
||||||
|
],
|
||||||
|
"defect_class": "correctness",
|
||||||
|
"inline_tier": "blocking",
|
||||||
|
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/yield-return-outside-function.yml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# apps/api (index)
|
||||||
|
dir: apps/api
|
||||||
|
|
||||||
|
## role
|
||||||
|
Backend API service that provides a self-hosted FastAPI server for managing development projects, git repositories, and containerized tools via Docker.
|
||||||
|
## parent
|
||||||
|
index: apps/.pi-map.index.md
|
||||||
|
map: apps/.pi-map.md
|
||||||
|
## children
|
||||||
|
- apps/api/.pi-lens
|
||||||
|
index: apps/api/.pi-lens/.pi-map.index.md
|
||||||
|
map: apps/api/.pi-lens/.pi-map.md
|
||||||
|
- apps/api/.pytest_cache
|
||||||
|
index: apps/api/.pytest_cache/.pi-map.index.md
|
||||||
|
map: apps/api/.pytest_cache/.pi-map.md
|
||||||
|
- apps/api/.venv-test
|
||||||
|
index: apps/api/.venv-test/.pi-map.index.md
|
||||||
|
map: apps/api/.venv-test/.pi-map.md
|
||||||
|
- apps/api/alembic
|
||||||
|
index: apps/api/alembic/.pi-map.index.md
|
||||||
|
map: apps/api/alembic/.pi-map.md
|
||||||
|
- apps/api/src
|
||||||
|
index: apps/api/src/.pi-map.index.md
|
||||||
|
map: apps/api/src/.pi-map.md
|
||||||
|
- apps/api/tests
|
||||||
|
index: apps/api/tests/.pi-map.index.md
|
||||||
|
map: apps/api/tests/.pi-map.md
|
||||||
|
- apps/api/uploads
|
||||||
|
index: apps/api/uploads/.pi-map.index.md
|
||||||
|
map: apps/api/uploads/.pi-map.md
|
||||||
|
## files
|
||||||
|
- .dockerignore
|
||||||
|
- Dockerfile
|
||||||
|
- README.md
|
||||||
|
- alembic.ini
|
||||||
|
- pyproject.toml
|
||||||
|
- uv.lock
|
||||||
|
- wait-for-db.sh
|
||||||
|
## links
|
||||||
|
index: apps/api/.pi-map.index.md
|
||||||
|
map: apps/api/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# apps/api
|
||||||
|
dir: apps/api
|
||||||
|
|
||||||
|
index: apps/api/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Backend API service that provides a self-hosted FastAPI server for managing development projects, git repositories, and containerized tools via Docker.
|
||||||
|
## files
|
||||||
|
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and avoid copying unnecessary files into containers. | dep: Docker
|
||||||
|
- Dockerfile | Multi-stage Docker build for a Python application with Docker socket access, Cloudflare tunneling, and database dependency waiting | dep: python:3.11-slim, gcc, libpq-dev, docker-ce-cli, docker-compose-plugin, cloudflared, uvicorn, pyproject.toml dependencies
|
||||||
|
- README.md | Documentation for a self-hosted FastAPI backend API that manages projects, git repositories, and development tools via Docker instances. | dep: FastAPI, SQLAlchemy, PostgreSQL, asyncpg, Alembic, Docker, Docker Compose, Authentik, uvicorn, pytest, ruff, mypy
|
||||||
|
- alembic.ini | Configuration file for Alembic database migration tool connecting to a PostgreSQL database with async driver | dep: alembic, sqlalchemy, asyncpg, PostgreSQL
|
||||||
|
- pyproject.toml | Defines Python project metadata, dependencies, and tool configurations for a FastAPI-based backend API called "headquarter-api" | dep: fastapi, uvicorn, sqlalchemy, asyncpg, alembic, pydantic, pydantic-settings, python-multipart, httpx, structlog, cryptography, pytest, pytest-asyncio, mypy, ruff, aiosqlite
|
||||||
|
- uv.lock | Lock file for the uv Python package manager that pins exact dependency versions and their artifact hashes for reproducible installations | dep: uv, Python 3.11+, aiosqlite, alembic, annotated-doc, annotated-types, anyio, ast-serialize, asyncpg, and many other PyPI packages
|
||||||
|
- wait-for-db.sh | Wait for a PostgreSQL database to become available before executing a command, with configurable retry logic. | dep: nc (netcat), sh (POSIX shell), sleep
|
||||||
|
## arch
|
||||||
|
Modern Python async architecture using FastAPI with SQLAlchemy/alembic for PostgreSQL, uv for dependency management, multi-stage Docker builds with Cloudflare tunneling, and health-check orchestration for database readiness.
|
||||||
|
## tags
|
||||||
|
docker, alembic, python, database, fastapi, postgresql, asyncpg, uvicorn
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
+3
-2
@@ -27,6 +27,7 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
git \
|
git \
|
||||||
|
openssh-client \
|
||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
@@ -49,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 that manages schema versioning and changes for the API's PostgreSQL database using Alembic with async SQLAlchemy.
|
||||||
|
## 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,23 @@
|
|||||||
|
# apps/api/alembic
|
||||||
|
dir: apps/api/alembic
|
||||||
|
|
||||||
|
index: apps/api/alembic/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Database migration tooling that manages schema versioning and changes for the API's PostgreSQL database using Alembic with async SQLAlchemy.
|
||||||
|
## files
|
||||||
|
- env.py | Configures Alembic database migration environment with async SQLAlchemy support for a project. | dep: alembic, sqlalchemy, sqlalchemy.ext.asyncio, logging.config, asyncio, src.config, src.models
|
||||||
|
- script.py.mako | Alembic database migration script template that generates upgrade/downgrade functions for SQLAlchemy schema migrations | dep: alembic, sqlalchemy
|
||||||
|
## arch
|
||||||
|
Template-driven migration pattern using Alembic's declarative configuration with async engine setup, environment context management, and auto-generated revision scripts with upgrade/downgrade function pairs.
|
||||||
|
## tags
|
||||||
|
sqlalchemy, alembic, database, migration, asyncio, config, src, env
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## 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,67 @@
|
|||||||
|
# apps/api/alembic/versions (index)
|
||||||
|
dir: apps/api/alembic/versions
|
||||||
|
|
||||||
|
## role
|
||||||
|
Database schema evolution management for the API application, tracking incremental changes to tables supporting users, authentication, projects, git repositories, tool types/instances, config profiles, workspaces, monitoring, and notifications.
|
||||||
|
## parent
|
||||||
|
index: apps/api/alembic/.pi-map.index.md
|
||||||
|
map: apps/api/alembic/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- 0001_initial_schema.py
|
||||||
|
- 0002_refresh_tokens.py
|
||||||
|
- 0003_user_configs.py
|
||||||
|
- 0004_tool_types.py
|
||||||
|
- 0005_ssh_keys_timestamps.py
|
||||||
|
- 0006_tool_instances.py
|
||||||
|
- 0007_instance_container_name.py
|
||||||
|
- 0008_tool_type_category.py
|
||||||
|
- 0009_tool_configs.py
|
||||||
|
- 0010_tool_type_default_port.py
|
||||||
|
- 0011_tool_instance_tunnel_fields.py
|
||||||
|
- 0012_default_port_req.py
|
||||||
|
- 0013_add_config_profiles.py
|
||||||
|
- 0013_add_probe_result.py
|
||||||
|
- 0014_add_profile_resolver_fields.py
|
||||||
|
- 0014_merge_heads.py
|
||||||
|
- 0015_single_interface.py
|
||||||
|
- 069d3da4dc9b_add_ssh_key_id_to_config_profiles.py
|
||||||
|
- 20260527160017_add_pi_agent_tool_type.py
|
||||||
|
- 2026_05_22_add_clone_mode.py
|
||||||
|
- 2026_05_23_remove_is_builtin.py
|
||||||
|
- 2026_05_24_220141_add_startup_command.py
|
||||||
|
- 2026_05_24_add_config_profiles.py
|
||||||
|
- 2026_05_26_add_git_mounts.py
|
||||||
|
- 2026_05_27_external_repos.py
|
||||||
|
- 2026_05_28_add_monitoring_tables.py
|
||||||
|
- 2026_05_28_add_terminal_sessions_table.py
|
||||||
|
- 2026_05_28_add_tool_definition_manifests.py
|
||||||
|
- 2026_05_28_drop_tool_configs_and_config_folders.py
|
||||||
|
- 2026_05_29_add_notifications_table.py
|
||||||
|
- 2026_05_29_add_ssh_key_ids_to_tool_instances.py
|
||||||
|
- 2026_05_29_drop_ssh_key_id_from_config_profiles.py
|
||||||
|
- 2026_05_29_fix_code_server_bind_addr.py
|
||||||
|
- 2026_05_29_fix_code_server_bind_addr_port.py
|
||||||
|
- 2026_05_29_fix_web_tool_bind_address.py
|
||||||
|
- 2026_05_29_remove_lsio_command_override.py
|
||||||
|
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py
|
||||||
|
- 2026_06_01_add_workspaces.py
|
||||||
|
- 398082499c30_add_tool_config_fields.py
|
||||||
|
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py
|
||||||
|
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py
|
||||||
|
- 8ed7dd80973d_create_config_folders_table.py
|
||||||
|
- af8512103d67_add_tool_type_fields.py
|
||||||
|
- f3d2dc90ba3a_merge_single_interface_and_clone_mode.py
|
||||||
|
## links
|
||||||
|
index: apps/api/alembic/versions/.pi-map.index.md
|
||||||
|
map: apps/api/alembic/versions/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change versions behavior
|
||||||
|
read: 0001_initial_schema.py, 0002_refresh_tokens.py, 0003_user_configs.py
|
||||||
|
- change versions CLI
|
||||||
|
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
|
||||||
|
- change versions config
|
||||||
|
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# apps/api/alembic/versions
|
||||||
|
dir: apps/api/alembic/versions
|
||||||
|
|
||||||
|
index: apps/api/alembic/versions/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Database schema evolution management for the API application, tracking incremental changes to tables supporting users, authentication, projects, git repositories, tool types/instances, config profiles, workspaces, monitoring, and notifications.
|
||||||
|
## files
|
||||||
|
- 0001_initial_schema.py | Defines the initial database schema migration creating five tables (users, ssh_keys, projects, git_repositories, user_configs) with relationships, indexes, and constraints using Alembic. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.String, call:postgresql.UUID, call:sa.DateTime, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:op.create_index, call:op.f, call:sa.Text, call:sa.ForeignKeyConstraint, call:sa.Boolean, call:postgresql.JSONB, func:downgrade() → None, call:op.drop_table, call:op.drop_index, call:op.f | dep: alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
|
||||||
|
- 0002_refresh_tokens.py | Alembic database migration that creates a refresh_tokens table with indexes for user authentication token management | exp: func:upgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.String, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:inspector.get_indexes, call:op.f, call:op.create_index, func:downgrade() → None, call:op.get_bind, call:sa.inspect, call:inspector.has_table, call:inspector.get_indexes, call:op.f, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
|
- 0003_user_configs.py | Alembic database migration that creates a user_configs table with JSON configuration storage linked to users | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.JSON, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, func:downgrade() → None, call:op.drop_table | dep: typing, alembic, sqlalchemy
|
||||||
|
- 0004_tool_types.py | Alembic database migration that creates a tool_types table with metadata, templates, and versioning columns for a tool management system. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.ForeignKey, call:sa.DateTime, call:sa.text, func:downgrade() → None, call:op.drop_table | dep: typing, alembic, sqlalchemy
|
||||||
|
- 0005_ssh_keys_timestamps.py | Alembic database migration that adds created_at and updated_at timestamp columns to the ssh_keys table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.DateTime, call:sa.text, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
|
- 0006_tool_instances.py | Alembic database migration that creates a tool_instances table with columns for tracking deployed tool instances, their status, container info, and foreign key relationships to tool_types, git_repositories, projects, and users. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.String, call:sa.Integer, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
|
- 0007_instance_container_name.py | Alembic database migration that adds a nullable container_name column to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
|
- 0008_tool_type_category.py | Alembic database migration that adds `category` and `interfaces` columns to the `tool_types` table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
|
- 0009_tool_configs.py | Alembic database migration that creates a tool_configs table with UUID keys, foreign key relationships, and indexes for storing user/project tool configuration settings. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.String, call:sa.Text, call:sa.DateTime, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
|
||||||
|
- 0010_tool_type_default_port.py | Alembic database migration that adds a nullable default_port column to the tool_types table. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Integer, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
|
- 0011_tool_instance_tunnel_fields.py | Alembic database migration that adds tunnel-related fields (public_url and tunnel_id) to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
|
- 0012_default_port_req.py | Alembic database migration that populates null default_port values for existing tool types and then makes the column non-nullable | exp: func:upgrade() → None, call:op.execute, call:op.alter_column, call:sa.Integer, func:downgrade() → None, call:op.alter_column, call:sa.Integer | dep: typing, alembic, sqlalchemy
|
||||||
|
- 0013_add_config_profiles.py | Alembic database migration that adds config profiles, includes, mounts tables and links tool instances to profiles with defensive idempotent checks | exp: func:_table_exists(table_name: str) → bool, call:sa.inspect(op.get_bind()).has_table, call:op.get_bind, func:_column_exists(table_name: str, column_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_columns, call:op.get_bind, func:_index_exists(table_name: str, index_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_indexes, call:op.get_bind, func:_foreign_key_exists(table_name: str, constrained_columns: list[str], referred_table: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, func:upgrade() → None, call:_table_exists, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.String, call:sa.Text, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:_index_exists, call:op.create_index, call:sa.Integer, call:_column_exists, call:op.add_column, call:_foreign_key_exists, call:op.create_foreign_key, func:downgrade() → None, call:op.drop_index, call:op.drop_constraint, call:op.drop_column, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
|
- 0013_add_probe_result.py | Alembic database migration that adds a JSON probe_result column to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
|
- 0014_add_profile_resolver_fields.py | Alembic database migration that adds profile resolver fields (project_id, tool_type_id, environment_variables, etc.) to config_profiles table and restructures config_mounts table (renaming mount_path to target_path, adding mode/files, removing content/source_profile_id). | exp: func:_table_exists(table_name: str) → bool, call:sa.inspect(op.get_bind()).has_table, call:op.get_bind, func:_column_exists(table_name: str, column_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_columns, call:op.get_bind, func:_index_exists(table_name: str, index_name: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_indexes, call:op.get_bind, func:_foreign_key_exists(table_name: str, constrained_columns: list[str], referred_table: str) → bool, call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, func:_foreign_key_names_for_column(table_name: str, column_name: str) → list[str], call:_table_exists, call:sa.inspect(op.get_bind()).get_foreign_keys, call:op.get_bind, call:foreign_key.get, call:names.append, func:upgrade() → None, call:_column_exists, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:sa.JSON, call:sa.Text, call:sa.Integer, call:sa.Boolean, call:_foreign_key_exists, call:op.create_foreign_key, call:_index_exists, call:op.create_index, call:op.alter_column, call:sa.String, call:_foreign_key_names_for_column, call:op.drop_constraint, call:op.drop_column, func:downgrade() → None, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:sa.Text, call:op.drop_column, call:op.alter_column, call:op.drop_index, call:op.drop_constraint | dep: collections.abc, alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
|
- 0014_merge_heads.py | Alembic merge migration that reconciles two divergent migration branches into a single history line | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
|
||||||
|
- 0015_single_interface.py | Alembic database migration that replaces a JSON array `interfaces` column with `interface_type` string and `requires_port` boolean columns in the `tool_types` table, with dialect-specific data migration for PostgreSQL and SQLite. | exp: func:_get_dialect() → str, call:op.get_bind, func:upgrade() → None, call:_get_dialect, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Boolean, call:op.execute, call:op.alter_column, call:op.drop_column, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:_get_dialect, call:op.drop_constraint, call:op.add_column, call:sa.Column, call:postgresql.JSONB, call:sa.Text, call:op.execute, call:sa.JSON, call:op.drop_column | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, postgresql (dialect)
|
||||||
|
- 069d3da4dc9b_add_ssh_key_id_to_config_profiles.py | Alembic database migration that adds a nullable UUID foreign key column `ssh_key_id` to the `config_profiles` table referencing `ssh_keys.id` with SET NULL on delete | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Uuid, call:sa.ForeignKey, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
|
||||||
|
- 20260527160017_add_pi_agent_tool_type.py | Alembic database migration that adds a "pi-agent" terminal-based coding tool type to a tool_types table with Docker configuration templates | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'") ).fetchone, call:sa.text, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text | dep: json, typing, alembic, uuid, sqlalchemy
|
||||||
|
- 2026_05_22_add_clone_mode.py | Alembic database migration that adds ssh_key_id foreign key to git_repositories table and clone_mode/branch columns to tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:postgresql.UUID, call:op.create_foreign_key, call:sa.String, func:downgrade() → None, call:op.drop_column, call:op.drop_constraint | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
|
- 2026_05_23_remove_is_builtin.py | Alembic database migration to remove the `is_builtin` column from the `tool_types` table | exp: func:upgrade() → None, call:op.execute, func:downgrade() → None, call:op.add_column, call:sa.Column, call:sa.Boolean | dep: alembic, sqlalchemy
|
||||||
|
- 2026_05_24_220141_add_startup_command.py | Alembic database migration that adds a nullable `startup_command` text column to the `tool_types` table. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Text, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
|
- 2026_05_24_add_config_profiles.py | Alembic database migration that creates config_profiles and config_profile_includes tables with indexes, and adds a foreign key column to tool_instances for managing user configuration profiles. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.ForeignKey, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:sa.Boolean, call:sa.DateTime, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:op.create_index, call:sa.Integer, call:op.add_column, func:downgrade() → None, call:op.drop_index, call:op.drop_column, call:op.drop_table | dep: typing, alembic, sqlalchemy.dialects, sqlalchemy, postgresql dialect
|
||||||
|
- 2026_05_26_add_git_mounts.py | Alembic database migration that adds a git_mounts JSON column to the config_profiles table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: typing, alembic, sqlalchemy
|
||||||
|
- 2026_05_27_external_repos.py | Alembic database migration that makes project_id nullable in git_repositories table to support external repositories and expands alembic_version version_num column to 64 characters. | exp: func:upgrade() → None, call:op.execute, call:op.alter_column, call:sa.UUID, func:downgrade() → None, call:op.alter_column, call:sa.UUID, call:op.execute | dep: typing, alembic, sqlalchemy
|
||||||
|
- 2026_05_28_add_monitoring_tables.py | Alembic database migration that creates monitoring tables (instance_events and health_checks) with indexes for tracking tool instance events and health checks | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.Boolean, call:sa.Integer, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
||||||
|
- 2026_05_28_add_terminal_sessions_table.py | Alembic database migration that creates a terminal_sessions table with tracking columns and foreign key to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.DateTime, call:sa.text, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:op.f, func:downgrade() → None, call:op.drop_index, call:op.f, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
||||||
|
- 2026_05_28_add_tool_definition_manifests.py | Alembic database migration that creates a tool_definition_manifests table, adds manifest-related columns to tool_types and tool_instances, and migrates existing pi-agent data from Dockerfile templates to JSON-based manifest definitions | exp: func:upgrade() → None, call:op.get_bind, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.UniqueConstraint, call:sa.ForeignKeyConstraint, call:sa.CheckConstraint, call:conn.execute, call:sa.text, call:result.fetchone, call:op.add_column, call:op.create_foreign_key, call:op.drop_constraint, call:op.execute, call:json.dumps, call:str, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_column, call:op.drop_constraint, call:op.drop_table | dep: json, uuid, typing, alembic, sqlalchemy
|
||||||
|
- 2026_05_28_drop_tool_configs_and_config_folders.py | Alembic database migration that drops `tool_configs` and `config_folders` tables with conditional existence checks and full downgrade recreation | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:op.drop_table, func:downgrade() → None, call:op.create_table, call:sa.Column, call:sa.UUID, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.Boolean, call:sa.TIMESTAMP, call:sa.func.now, call:sa.PrimaryKeyConstraint, call:sa.Integer | dep: typing, alembic, sqlalchemy
|
||||||
|
- 2026_05_29_add_notifications_table.py | Alembic database migration that creates a notifications table with user-linked, categorized, severity-graded messages supporting read/dismissed tracking and optimized querying indexes. | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.Text, call:sa.JSON, call:sa.DateTime, call:sa.func.now, call:sa.ForeignKeyConstraint, call:sa.PrimaryKeyConstraint, call:op.create_index, call:sa.text, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
||||||
|
- 2026_05_29_add_ssh_key_ids_to_tool_instances.py | Alembic database migration that adds a JSON column named ssh_key_ids to the tool_instances table | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.JSON, func:downgrade() → None, call:op.drop_column | dep: alembic, sqlalchemy
|
||||||
|
- 2026_05_29_drop_ssh_key_id_from_config_profiles.py | Alembic database migration that removes the ssh_key_id column from the config_profiles table | exp: func:upgrade() → None, call:op.drop_column, func:downgrade() → None, call:op.add_column, call:sa.Column, call:sa.Uuid, call:sa.ForeignKey | dep: alembic, sqlalchemy
|
||||||
|
- 2026_05_29_fix_code_server_bind_addr.py | Alembic database migration that fixes code-server tool type compose templates by replacing deprecated `--bind-addr` flag with `--host` flag | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text(""" SELECT id, compose_template FROM tool_types WHERE name = 'code-server' AND compose_template LIKE '%--bind-addr%' """) ).fetchall, call:sa.text, call:compose_template.replace( "--bind-addr 0.0.0.0:8443", "--host 0.0.0.0" ).replace, call:print, func:downgrade() → None | dep: typing, alembic, sqlalchemy
|
||||||
|
- 2026_05_29_fix_code_server_bind_addr_port.py | Alembic database migration that fixes code-server Docker compose templates and instance files by replacing broken `--host` flags with correct `--bind-addr 0.0.0.0:port` configurations | exp: func:_fix_tool_type_templates(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, default_port FROM tool_types WHERE name = 'code-server' AND compose_template LIKE '%--host%' """) ).fetchall, call:sa.text, call:compose_template.split, call:len, call:line.lstrip, call:new_lines.append, call:"\n".join, call:print, func:_fix_instance_compose_files(conn) → None, call:conn.execute( sa.text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'tool_instances' AND column_name = 'compose_path' """) ).fetchone, call:sa.text, call:print, call:conn.execute( sa.text(""" SELECT id, compose_path, tool_type_id FROM tool_instances WHERE compose_path IS NOT NULL """) ).fetchall, call:Path, call:path.exists, call:path.read_text, call:conn.execute( sa.text(""" SELECT default_port FROM tool_types WHERE id = :id """), {"id": tool_type_id}, ).fetchone, call:yaml.safe_load, call:data["services"].values, call:path.write_text, call:yaml.dump, func:upgrade() → None, call:op.get_bind, call:_fix_tool_type_templates, call:_fix_instance_compose_files, func:downgrade() → None | dep: typing, alembic, yaml, pathlib, sqlalchemy
|
||||||
|
- 2026_05_29_fix_web_tool_bind_address.py | Alembic database migration that updates code-server and jupyter-notebook tool type compose templates to bind to 0.0.0.0 | exp: func:_fix_code_server_compose(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, definition_type FROM tool_types WHERE name = 'code-server' """) ).fetchone, call:sa.text, call:compose_template.split, call:enumerate, call:len, call:line.lstrip, call:new_lines.append, call:image_line.lstrip, call:new_lines.index, call:new_lines.insert, call:"\n".join, call:print, func:_fix_jupyter_compose(conn) → None, call:conn.execute( sa.text(""" SELECT id, compose_template, definition_type FROM tool_types WHERE name = 'jupyter-notebook' """) ).fetchone, call:sa.text, call:compose_template.split, call:enumerate, call:new_lines.append, call:len, call:line.lstrip, call:"\n".join, call:print, func:upgrade() → None, call:op.get_bind, call:_fix_code_server_compose, call:_fix_jupyter_compose, func:downgrade() → None | dep: typing, alembic, sqlalchemy
|
||||||
|
- 2026_05_29_remove_lsio_command_override.py | Alembic database migration that removes broken command overrides containing --bind-addr or --host flags from LinuxServer.io code-server Docker Compose templates in both database tool_types records and on-disk instance compose files. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute( sa.text(""" SELECT id, compose_template FROM tool_types WHERE name = 'code-server' """) ).fetchall, call:sa.text, call:yaml.safe_load, call:data["services"].values, call:svc.get, call:yaml.dump, call:print, call:conn.execute( sa.text(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'tool_instances' AND column_name = 'compose_path' """) ).fetchone, call:conn.execute( sa.text(""" SELECT id, compose_path FROM tool_instances WHERE compose_path IS NOT NULL """) ).fetchall, call:Path, call:path.exists, call:path.read_text, call:path.write_text, func:downgrade() → None | dep: collections.abc, alembic, yaml, pathlib, sqlalchemy, pathlib.Path, information_schema
|
||||||
|
- 2026_05_29_remove_ssh_keys_mount_from_manifest.py | Alembic database migration that removes (or restores) the ssh_keys mount from a JSON manifest stored in the tool_definition_manifests table for the pi-agent tool definition. | exp: func:upgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:len, call:m.get, call:json.dumps, func:downgrade() → None, call:op.get_bind, call:conn.execute, call:sa.text, call:result.fetchone, call:isinstance, call:json.loads, call:manifest.get, call:any, call:m.get, call:mounts.append, call:json.dumps | dep: json, typing, alembic, sqlalchemy
|
||||||
|
- 2026_06_01_add_workspaces.py | Alembic database migration that creates a workspaces table with foreign keys to git_repositories and users, adds indexes, and adds a workspace_id column to tool_instances | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:sa.Uuid, call:sa.String, call:sa.ForeignKey, call:sa.DateTime, call:sa.text, call:sa.UniqueConstraint, call:op.create_index, call:op.add_column, func:downgrade() → None, call:op.drop_index, call:op.drop_column, call:op.drop_table | dep: collections.abc, alembic, sqlalchemy
|
||||||
|
- 398082499c30_add_tool_config_fields.py | Alembic database migration that adds five new columns (port_override, start_command, working_directory, environment_variables, volumes) to the tool_configs table with a port range check constraint. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.Integer, call:sa.Text, call:postgresql.JSONB, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:op.drop_constraint, call:op.drop_column | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
|
- 6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py | Alembic database migration that merges two parallel revision branches (removing is_builtin and adding config_profiles) into a single history line | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
|
||||||
|
- 86cec91fdb00_merge_profile_resolver_and_workspaces_.py | Alembic database migration that merges two divergent migration branches (profile resolver and workspaces) into a single head | exp: func:upgrade() → None, func:downgrade() → None | dep: alembic
|
||||||
|
- 8ed7dd80973d_create_config_folders_table.py | Alembic database migration that creates a config_folders table with user-owned configuration folders supporting JSONB file storage and project overrides | exp: func:upgrade() → None, call:op.create_table, call:sa.Column, call:postgresql.UUID, call:sa.text, call:sa.ForeignKey, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:sa.Boolean, call:sa.DateTime, call:sa.UniqueConstraint, call:op.create_index, func:downgrade() → None, call:op.drop_index, call:op.drop_table | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
|
- af8512103d67_add_tool_type_fields.py | Alembic database migration that adds new columns (definition_type, dockerfile_template, build_context, readiness_probe) to the tool_types table with a CHECK constraint on definition_type. | exp: func:upgrade() → None, call:op.add_column, call:sa.Column, call:sa.String, call:sa.Text, call:postgresql.JSONB, call:op.create_check_constraint, call:sa.text, func:downgrade() → None, call:op.drop_constraint, call:op.drop_column | dep: alembic, sqlalchemy.dialects, sqlalchemy, sqlalchemy.dialects.postgresql
|
||||||
|
- f3d2dc90ba3a_merge_single_interface_and_clone_mode.py | Alembic database migration that merges two prior revisions (single_interface and clone_mode) into a single migration path | exp: func:upgrade() → None, func:downgrade() → None | dep: typing, alembic
|
||||||
|
## arch
|
||||||
|
Linear and branched Alembic migration pattern with merge migrations to reconcile divergent branches, using incremental revision files with upgrade/downgrade functions, idempotent operations, and occasional data migrations for schema transformations.
|
||||||
|
## tags
|
||||||
|
column, table, call:op.drop, key, alembic, downgrade, upgrade, constraint
|
||||||
|
## symbols
|
||||||
|
- upgrade
|
||||||
|
- downgrade
|
||||||
|
- _table_exists
|
||||||
|
- _column_exists
|
||||||
|
- _index_exists
|
||||||
|
- _foreign_key_exists
|
||||||
|
- _foreign_key_names_for_column
|
||||||
|
- _get_dialect
|
||||||
|
## workflows
|
||||||
|
- change versions behavior
|
||||||
|
read: 0001_initial_schema.py, 0002_refresh_tokens.py, 0003_user_configs.py
|
||||||
|
- change versions CLI
|
||||||
|
read: 2026_05_24_220141_add_startup_command.py, 2026_05_29_remove_lsio_command_override.py
|
||||||
|
- change versions config
|
||||||
|
read: 0003_user_configs.py, 0009_tool_configs.py, 0013_add_config_profiles.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -5,7 +5,7 @@ Revises: 0012_default_port_req
|
|||||||
Create Date: 2026-05-24 12:00:00.000000
|
Create Date: 2026-05-24 12:00:00.000000
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -13,81 +13,181 @@ from sqlalchemy.dialects import postgresql
|
|||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "0013_add_config_profiles"
|
revision: str = "0013_add_config_profiles"
|
||||||
down_revision: Union[str, None] = "0012_default_port_req"
|
down_revision: str | None = "0012_default_port_req"
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: Union[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:
|
def upgrade() -> None:
|
||||||
# Create config_profiles table
|
# Earlier branches may already have created config_profiles. Keep this
|
||||||
op.create_table(
|
# migration defensive so databases can converge onto the current graph.
|
||||||
"config_profiles",
|
if not _table_exists("config_profiles"):
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
op.create_table(
|
||||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
"config_profiles",
|
||||||
sa.Column("name", sa.String(length=255), nullable=False),
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.Column("description", sa.Text(), nullable=True),
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
sa.Column(
|
||||||
sa.PrimaryKeyConstraint("id"),
|
"created_at",
|
||||||
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
sa.DateTime(timezone=True),
|
||||||
)
|
server_default=sa.text("NOW()"),
|
||||||
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
|
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"])
|
||||||
|
|
||||||
# Create config_includes table
|
if not _table_exists("config_includes"):
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"config_includes",
|
"config_includes",
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.Column("profile_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(
|
||||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
"included_profile_id", postgresql.UUID(as_uuid=True), nullable=False
|
||||||
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.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||||
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
sa.Column(
|
||||||
sa.ForeignKeyConstraint(["included_profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
"created_at",
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.DateTime(timezone=True),
|
||||||
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
|
server_default=sa.text("NOW()"),
|
||||||
)
|
nullable=False,
|
||||||
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
|
),
|
||||||
op.create_index("idx_config_includes_included", "config_includes", ["included_profile_id"])
|
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"]
|
||||||
|
)
|
||||||
|
|
||||||
# Create config_mounts table
|
if not _table_exists("config_mounts"):
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"config_mounts",
|
"config_mounts",
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.Column("profile_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("mount_path", sa.String(length=1024), nullable=False),
|
||||||
sa.Column("content", sa.Text(), nullable=True),
|
sa.Column("content", sa.Text(), nullable=True),
|
||||||
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), 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("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(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
"created_at",
|
||||||
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
sa.DateTime(timezone=True),
|
||||||
sa.ForeignKeyConstraint(["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"),
|
server_default=sa.text("NOW()"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
nullable=False,
|
||||||
)
|
),
|
||||||
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
|
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"])
|
||||||
|
|
||||||
# Add selected_profile_id to tool_instances
|
if not _column_exists("tool_instances", "selected_profile_id"):
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"tool_instances",
|
"tool_instances",
|
||||||
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_foreign_key(
|
if not _foreign_key_exists(
|
||||||
"fk_tool_instances_selected_profile",
|
"tool_instances", ["selected_profile_id"], "config_profiles"
|
||||||
"tool_instances",
|
):
|
||||||
"config_profiles",
|
op.create_foreign_key(
|
||||||
["selected_profile_id"],
|
"fk_tool_instances_selected_profile",
|
||||||
["id"],
|
"tool_instances",
|
||||||
ondelete="SET NULL",
|
"config_profiles",
|
||||||
)
|
["selected_profile_id"],
|
||||||
op.create_index("idx_tool_instances_selected_profile", "tool_instances", ["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:
|
def downgrade() -> None:
|
||||||
# Remove selected_profile_id from tool_instances
|
# Remove selected_profile_id from tool_instances
|
||||||
op.drop_index("idx_tool_instances_selected_profile", table_name="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_constraint(
|
||||||
|
"fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey"
|
||||||
|
)
|
||||||
op.drop_column("tool_instances", "selected_profile_id")
|
op.drop_column("tool_instances", "selected_profile_id")
|
||||||
|
|
||||||
# Drop config_mounts
|
# Drop config_mounts
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add probe_result to tool_instances
|
||||||
|
|
||||||
|
Revision ID: 0013_add_probe_result
|
||||||
|
Revises: 0012_default_port_req
|
||||||
|
Create Date: 2026-05-22 21:45:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0013_add_probe_result"
|
||||||
|
down_revision: Union[str, None] = "0012_default_port_req"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("probe_result", postgresql.JSON, nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "probe_result")
|
||||||
@@ -5,7 +5,7 @@ Revises: 0013_add_config_profiles
|
|||||||
Create Date: 2026-05-24 14:00:00.000000
|
Create Date: 2026-05-24 14:00:00.000000
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -13,82 +13,141 @@ from sqlalchemy.dialects import postgresql
|
|||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "0014_add_profile_resolver_fields"
|
revision: str = "0014_add_profile_resolver_fields"
|
||||||
down_revision: Union[str, None] = "0013_add_config_profiles"
|
down_revision: str | None = "0013_add_config_profiles"
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: Union[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:
|
def upgrade() -> None:
|
||||||
# Add fields to config_profiles
|
if not _column_exists("config_profiles", "project_id"):
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"config_profiles",
|
"config_profiles",
|
||||||
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.add_column(
|
if not _column_exists("config_profiles", "tool_type_id"):
|
||||||
"config_profiles",
|
op.add_column(
|
||||||
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
|
"config_profiles",
|
||||||
)
|
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
op.add_column(
|
)
|
||||||
"config_profiles",
|
if not _column_exists("config_profiles", "environment_variables"):
|
||||||
sa.Column("environment_variables", sa.JSON(), nullable=True),
|
op.add_column(
|
||||||
)
|
"config_profiles",
|
||||||
op.add_column(
|
sa.Column("environment_variables", sa.JSON(), nullable=True),
|
||||||
"config_profiles",
|
)
|
||||||
sa.Column("start_command", sa.Text(), nullable=True),
|
if not _column_exists("config_profiles", "start_command"):
|
||||||
)
|
op.add_column(
|
||||||
op.add_column(
|
"config_profiles",
|
||||||
"config_profiles",
|
sa.Column("start_command", sa.Text(), nullable=True),
|
||||||
sa.Column("working_directory", sa.Text(), nullable=True),
|
)
|
||||||
)
|
if not _column_exists("config_profiles", "working_directory"):
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"config_profiles",
|
"config_profiles",
|
||||||
sa.Column("port", sa.Integer(), nullable=True),
|
sa.Column("working_directory", sa.Text(), nullable=True),
|
||||||
)
|
)
|
||||||
op.add_column(
|
if not _column_exists("config_profiles", "port"):
|
||||||
"config_profiles",
|
op.add_column("config_profiles", sa.Column("port", sa.Integer(), nullable=True))
|
||||||
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
|
if not _column_exists("config_profiles", "is_default"):
|
||||||
)
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
# Add foreign keys for project and tool_type
|
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
op.create_foreign_key(
|
)
|
||||||
"fk_config_profiles_project",
|
|
||||||
"config_profiles",
|
if not _foreign_key_exists("config_profiles", ["project_id"], "projects"):
|
||||||
"projects",
|
op.create_foreign_key(
|
||||||
["project_id"],
|
"fk_config_profiles_project",
|
||||||
["id"],
|
"config_profiles",
|
||||||
ondelete="CASCADE",
|
"projects",
|
||||||
)
|
["project_id"],
|
||||||
op.create_foreign_key(
|
["id"],
|
||||||
"fk_config_profiles_tool_type",
|
ondelete="CASCADE",
|
||||||
"config_profiles",
|
)
|
||||||
"tool_types",
|
if not _foreign_key_exists("config_profiles", ["tool_type_id"], "tool_types"):
|
||||||
["tool_type_id"],
|
op.create_foreign_key(
|
||||||
["id"],
|
"fk_config_profiles_tool_type",
|
||||||
ondelete="CASCADE",
|
"config_profiles",
|
||||||
)
|
"tool_types",
|
||||||
|
["tool_type_id"],
|
||||||
# Create indices
|
["id"],
|
||||||
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
|
ondelete="CASCADE",
|
||||||
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
|
)
|
||||||
|
|
||||||
# Alter config_mounts: rename mount_path to target_path, add mode, change content to files JSON
|
if not _index_exists("config_profiles", "idx_config_profiles_project"):
|
||||||
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
|
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
|
||||||
op.add_column(
|
if not _index_exists("config_profiles", "idx_config_profiles_tool_type"):
|
||||||
"config_mounts",
|
op.create_index(
|
||||||
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
|
"idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"]
|
||||||
)
|
)
|
||||||
op.add_column(
|
|
||||||
"config_mounts",
|
if _column_exists("config_mounts", "mount_path") and not _column_exists(
|
||||||
sa.Column("files", sa.JSON(), nullable=True),
|
"config_mounts", "target_path"
|
||||||
)
|
):
|
||||||
# Drop the source_profile foreign key if it exists
|
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
|
||||||
op.drop_constraint(
|
if not _column_exists("config_mounts", "mode"):
|
||||||
"config_mounts_source_profile_id_fkey",
|
op.add_column(
|
||||||
"config_mounts",
|
"config_mounts",
|
||||||
type_="foreignkey",
|
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
|
||||||
)
|
)
|
||||||
op.drop_column("config_mounts", "content")
|
if not _column_exists("config_mounts", "files"):
|
||||||
op.drop_column("config_mounts", "source_profile_id")
|
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:
|
def downgrade() -> None:
|
||||||
@@ -104,11 +163,13 @@ def downgrade() -> None:
|
|||||||
op.drop_column("config_mounts", "files")
|
op.drop_column("config_mounts", "files")
|
||||||
op.drop_column("config_mounts", "mode")
|
op.drop_column("config_mounts", "mode")
|
||||||
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
|
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
|
||||||
|
|
||||||
# Restore config_profiles
|
# Restore config_profiles
|
||||||
op.drop_index("idx_config_profiles_tool_type", table_name="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_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_tool_type", "config_profiles", type_="foreignkey"
|
||||||
|
)
|
||||||
op.drop_constraint("fk_config_profiles_project", "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", "is_default")
|
||||||
op.drop_column("config_profiles", "port")
|
op.drop_column("config_profiles", "port")
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""merge migration heads
|
||||||
|
|
||||||
|
Revision ID: 0014_merge_heads
|
||||||
|
Revises: 0013_add_probe_result, 8ed7dd80973d
|
||||||
|
Create Date: 2026-05-22 21:50:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0014_merge_heads"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ("0013_add_probe_result", "8ed7dd80973d")
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""replace interfaces with interface_type and add requires_port
|
||||||
|
|
||||||
|
Revision ID: 0015_single_interface
|
||||||
|
Revises: 0014_merge_heads
|
||||||
|
Create Date: 2026-05-22 22:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0015_single_interface"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "0014_merge_heads"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dialect() -> str:
|
||||||
|
"""Get the current database dialect name."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
return conn.dialect.name
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
dialect = _get_dialect()
|
||||||
|
|
||||||
|
# Add new columns
|
||||||
|
op.add_column('tool_types', sa.Column('interface_type', sa.String(20), nullable=True))
|
||||||
|
op.add_column('tool_types', sa.Column('requires_port', sa.Boolean(), nullable=False, server_default='true'))
|
||||||
|
|
||||||
|
# Migrate data: take first element from interfaces JSON array
|
||||||
|
if dialect == 'postgresql':
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interface_type = COALESCE(
|
||||||
|
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
|
||||||
|
'web'
|
||||||
|
),
|
||||||
|
requires_port = CASE
|
||||||
|
WHEN COALESCE(
|
||||||
|
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
|
||||||
|
'web'
|
||||||
|
) = 'web' THEN true
|
||||||
|
ELSE false
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
# SQLite: interfaces is stored as JSON text, extract first array element
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interface_type = COALESCE(
|
||||||
|
(SELECT json_extract(value, '$[0]')
|
||||||
|
FROM json_each(interfaces) AS value
|
||||||
|
WHERE json_valid(interfaces)
|
||||||
|
LIMIT 1),
|
||||||
|
'web'
|
||||||
|
),
|
||||||
|
requires_port = CASE
|
||||||
|
WHEN COALESCE(
|
||||||
|
(SELECT json_extract(value, '$[0]')
|
||||||
|
FROM json_each(interfaces) AS value
|
||||||
|
WHERE json_valid(interfaces)
|
||||||
|
LIMIT 1),
|
||||||
|
'web'
|
||||||
|
) = 'web' THEN true
|
||||||
|
ELSE false
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Make interface_type non-nullable after data migration
|
||||||
|
op.alter_column('tool_types', 'interface_type', nullable=False)
|
||||||
|
|
||||||
|
# Drop old interfaces column
|
||||||
|
op.drop_column('tool_types', 'interfaces')
|
||||||
|
|
||||||
|
# Add CHECK constraint for interface_type (only on PostgreSQL; SQLite supports it too)
|
||||||
|
op.create_check_constraint('chk_interface_type', 'tool_types', sa.text("interface_type IN ('web', 'terminal')"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
dialect = _get_dialect()
|
||||||
|
|
||||||
|
# Drop CHECK constraint
|
||||||
|
op.drop_constraint('chk_interface_type', 'tool_types', type_='check')
|
||||||
|
|
||||||
|
# Add back interfaces column
|
||||||
|
if dialect == 'postgresql':
|
||||||
|
op.add_column('tool_types', sa.Column('interfaces', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='["web"]'))
|
||||||
|
|
||||||
|
# Migrate data back: wrap interface_type in array
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interfaces = jsonb_build_array(interface_type)
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
op.add_column('tool_types', sa.Column('interfaces', sa.JSON(), nullable=False, server_default='["web"]'))
|
||||||
|
|
||||||
|
# Migrate data back: wrap interface_type in array for SQLite
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interfaces = json_array(interface_type)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Drop new columns
|
||||||
|
op.drop_column('tool_types', 'requires_port')
|
||||||
|
op.drop_column('tool_types', 'interface_type')
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""add_ssh_key_id_to_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 069d3da4dc9b
|
||||||
|
Revises: 2026_05_29_add_notifications_table
|
||||||
|
Create Date: 2026-05-29 12:30:16.580532
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "069d3da4dc9b"
|
||||||
|
down_revision = "2026_05_29_add_notifications_table"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column(
|
||||||
|
"ssh_key_id",
|
||||||
|
sa.Uuid(),
|
||||||
|
sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("config_profiles", "ssh_key_id")
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""add pi agent tool type
|
||||||
|
|
||||||
|
Revision ID: 20260527_160017_add_pi_agent
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-05-27T16:00:17
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "20260527_160017_add_pi_agent"
|
||||||
|
down_revision: Union[str, None] = "2026_05_27_external_repos"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
PI_AGENT_ID = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Check if pi-agent already exists
|
||||||
|
conn = op.get_bind()
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
INSERT INTO tool_types (
|
||||||
|
id, name, display_name, description, category,
|
||||||
|
interface_type, requires_port, default_port,
|
||||||
|
definition_type, compose_template, dockerfile_template, required_variables,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:id, :name, :display_name, :description, :category,
|
||||||
|
:interface_type, :requires_port, :default_port,
|
||||||
|
:definition_type, :compose_template, :dockerfile_template, :required_variables,
|
||||||
|
now(), now()
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": PI_AGENT_ID,
|
||||||
|
"name": "pi-agent",
|
||||||
|
"display_name": "Pi Agent",
|
||||||
|
"description": "Pi coding agent terminal environment with nvim, ranger, and tmux",
|
||||||
|
"category": "development",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "dockerfile",
|
||||||
|
"compose_template": """services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
volumes:
|
||||||
|
- ${REPO_PATH}:/workspace
|
||||||
|
working_dir: /workspace
|
||||||
|
command: /bin/bash""",
|
||||||
|
"dockerfile_template": """# Pi Coding Agent - Terminal-based coding harness
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install base dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \\
|
||||||
|
curl \\
|
||||||
|
wget \\
|
||||||
|
git \\
|
||||||
|
neovim \\
|
||||||
|
ranger \\
|
||||||
|
tmux \\
|
||||||
|
htop \\
|
||||||
|
tree \\
|
||||||
|
jq \\
|
||||||
|
ca-certificates \\
|
||||||
|
python3 \\
|
||||||
|
python3-pip \\
|
||||||
|
build-essential \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js (required for Pi)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\
|
||||||
|
&& apt-get install -y nodejs \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Pi Coding Agent globally
|
||||||
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main \\
|
||||||
|
&& git config --global user.email "dev@headquarter.local" \\
|
||||||
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
|
# Create default tmux config
|
||||||
|
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||||
|
|
||||||
|
# Create default ranger config
|
||||||
|
RUN mkdir -p /home/user/.config/ranger \\
|
||||||
|
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||||
|
|
||||||
|
# Set up Pi config directory
|
||||||
|
RUN mkdir -p /home/user/.pi/agent
|
||||||
|
|
||||||
|
USER user
|
||||||
|
|
||||||
|
# Default to bash (Pi is invoked manually via `pi` command)
|
||||||
|
CMD ["/bin/bash"]""",
|
||||||
|
"required_variables": json.dumps(["REPO_PATH"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(
|
||||||
|
sa.text("DELETE FROM tool_types WHERE name = 'pi-agent'")
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""add_clone_mode_and_ssh_key_id
|
||||||
|
|
||||||
|
Revision ID: 2026_05_22_add_clone_mode
|
||||||
|
Revises: 0014_merge_heads
|
||||||
|
Create Date: 2026-05-22 20:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2026_05_22_add_clone_mode'
|
||||||
|
down_revision = '0015_single_interface'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add ssh_key_id to git_repositories
|
||||||
|
op.add_column('git_repositories', sa.Column('ssh_key_id', postgresql.UUID(), nullable=True))
|
||||||
|
op.create_foreign_key('fk_git_repositories_ssh_key', 'git_repositories', 'ssh_keys', ['ssh_key_id'], ['id'])
|
||||||
|
|
||||||
|
# Add clone_mode and branch to tool_instances
|
||||||
|
op.add_column('tool_instances', sa.Column('clone_mode', sa.String(20), nullable=False, server_default='mount'))
|
||||||
|
op.add_column('tool_instances', sa.Column('branch', sa.String(255), nullable=True, server_default='main'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop columns from tool_instances
|
||||||
|
op.drop_column('tool_instances', 'branch')
|
||||||
|
op.drop_column('tool_instances', 'clone_mode')
|
||||||
|
|
||||||
|
# Drop ssh_key_id from git_repositories
|
||||||
|
op.drop_constraint('fk_git_repositories_ssh_key', 'git_repositories', type_='foreignkey')
|
||||||
|
op.drop_column('git_repositories', 'ssh_key_id')
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""remove_is_builtin_from_tool_types
|
||||||
|
|
||||||
|
Revision ID: 2026_05_23_remove_is_builtin
|
||||||
|
Revises: 2026_05_22_add_clone_mode
|
||||||
|
Create Date: 2026-05-23 14:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2026_05_23_remove_is_builtin'
|
||||||
|
down_revision = 'f3d2dc90ba3a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Drop the is_builtin column from tool_types
|
||||||
|
op.execute("ALTER TABLE tool_types DROP COLUMN IF EXISTS is_builtin")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Add the is_builtin column back to tool_types
|
||||||
|
op.add_column('tool_types', sa.Column('is_builtin', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add startup_command to tool_types
|
||||||
|
|
||||||
|
Revision ID: 2026_05_24_220141
|
||||||
|
Revises: 6fc7bfcf199f
|
||||||
|
Create Date: 2026-05-24 22:01:41.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_24_220141"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "6fc7bfcf199f"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_types",
|
||||||
|
sa.Column("startup_command", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_types", "startup_command")
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""add_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 2026_05_24_add_config_profiles
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-05-24 14:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_24_add_config_profiles"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create config_profiles table
|
||||||
|
op.create_table(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("env_vars", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("runtime_hints", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("mounts", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="[]"),
|
||||||
|
sa.Column("files", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
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.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for config_profiles
|
||||||
|
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
|
||||||
|
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
|
||||||
|
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
|
||||||
|
|
||||||
|
# Create config_profile_includes table
|
||||||
|
op.create_table(
|
||||||
|
"config_profile_includes",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), 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.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_profile_includes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for config_profile_includes
|
||||||
|
op.create_index("idx_config_profile_includes_profile", "config_profile_includes", ["profile_id"])
|
||||||
|
op.create_index("idx_config_profile_includes_included", "config_profile_includes", ["included_profile_id"])
|
||||||
|
|
||||||
|
# Add selected_config_profile_id to tool_instances
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("selected_config_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("idx_tool_instances_config_profile", "tool_instances", ["selected_config_profile_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove selected_config_profile_id from tool_instances
|
||||||
|
op.drop_index("idx_tool_instances_config_profile", table_name="tool_instances")
|
||||||
|
op.drop_column("tool_instances", "selected_config_profile_id")
|
||||||
|
|
||||||
|
# Drop config_profile_includes table
|
||||||
|
op.drop_index("idx_config_profile_includes_included", table_name="config_profile_includes")
|
||||||
|
op.drop_index("idx_config_profile_includes_profile", table_name="config_profile_includes")
|
||||||
|
op.drop_table("config_profile_includes")
|
||||||
|
|
||||||
|
# Drop config_profiles table
|
||||||
|
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_index("idx_config_profiles_user", table_name="config_profiles")
|
||||||
|
op.drop_table("config_profiles")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add_git_mounts_to_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 2026_05_26_add_git_mounts
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-05-26 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_26_add_git_mounts"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "2026_05_24_220141"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("git_mounts", sa.JSON(), nullable=True, default=list),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("config_profiles", "git_mounts")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""make_project_id_nullable_in_git_repositories
|
||||||
|
|
||||||
|
Revision ID: 2026_05_27_external_repos
|
||||||
|
Revises: 2026_05_26_add_git_mounts
|
||||||
|
Create Date: 2026-05-27 08:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_27_external_repos"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "2026_05_26_add_git_mounts"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Expand alembic_version version_num to avoid truncation errors
|
||||||
|
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(64)")
|
||||||
|
|
||||||
|
# Make project_id nullable to allow external repositories
|
||||||
|
op.alter_column(
|
||||||
|
"git_repositories",
|
||||||
|
"project_id",
|
||||||
|
existing_type=sa.UUID(),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"git_repositories",
|
||||||
|
"project_id",
|
||||||
|
existing_type=sa.UUID(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(32)")
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""add monitoring tables
|
||||||
|
|
||||||
|
Revision ID: 2026_05_28_add_monitoring_tables
|
||||||
|
Revises: 2026_05_28_drop_tool_configs_and_config_folders
|
||||||
|
Create Date: 2026-05-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_28_add_monitoring_tables"
|
||||||
|
down_revision: str | None = "2026_05_28_drop_tool_configs_and_config_folders"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"instance_events",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"instance_id",
|
||||||
|
sa.Uuid(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("event_type", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"metadata",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="{}",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["instance_id"],
|
||||||
|
["tool_instances.id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["created_by"],
|
||||||
|
["users.id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_instance_events_instance_id",
|
||||||
|
"instance_events",
|
||||||
|
["instance_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_instance_events_created_at",
|
||||||
|
"instance_events",
|
||||||
|
["created_at"],
|
||||||
|
postgresql_using="btree",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_instance_events_event_type",
|
||||||
|
"instance_events",
|
||||||
|
["event_type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"health_checks",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"instance_id",
|
||||||
|
sa.Uuid(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("container_status", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("container_healthy", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("tunnel_healthy", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("exit_code", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("probe_status", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("probe_output", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"checked_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["instance_id"],
|
||||||
|
["tool_instances.id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_health_checks_instance_id",
|
||||||
|
"health_checks",
|
||||||
|
["instance_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_health_checks_checked_at",
|
||||||
|
"health_checks",
|
||||||
|
["checked_at"],
|
||||||
|
postgresql_using="btree",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_health_checks_checked_at", table_name="health_checks")
|
||||||
|
op.drop_index("idx_health_checks_instance_id", table_name="health_checks")
|
||||||
|
op.drop_table("health_checks")
|
||||||
|
op.drop_index("idx_instance_events_event_type", table_name="instance_events")
|
||||||
|
op.drop_index("idx_instance_events_created_at", table_name="instance_events")
|
||||||
|
op.drop_index("idx_instance_events_instance_id", table_name="instance_events")
|
||||||
|
op.drop_table("instance_events")
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""add terminal_sessions table
|
||||||
|
|
||||||
|
Revision ID: 2026_05_28_add_terminal_sessions
|
||||||
|
Revises: 20260527_160017_add_pi_agent
|
||||||
|
Create Date: 2026-05-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_28_add_terminal_sessions"
|
||||||
|
down_revision: str | None = "2026_05_28_add_tool_definition_manifests"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"terminal_sessions",
|
||||||
|
sa.Column("id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("instance_id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
onupdate=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["instance_id"], ["tool_instances.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_terminal_sessions_instance_id"),
|
||||||
|
"terminal_sessions",
|
||||||
|
["instance_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_terminal_sessions_instance_id"),
|
||||||
|
table_name="terminal_sessions",
|
||||||
|
)
|
||||||
|
op.drop_table("terminal_sessions")
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
"""add tool definition manifests
|
||||||
|
|
||||||
|
Revision ID: 2026_05_28_add_tool_definition_manifests
|
||||||
|
Revises: 20260527_160017_add_pi_agent
|
||||||
|
Create Date: 2026-05-28T11:00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_28_add_tool_definition_manifests"
|
||||||
|
down_revision: Union[str, None] = "20260527_160017_add_pi_agent"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
BASE_UBUNTU_ID = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
||||||
|
PI_AGENT_MANIFEST_ID = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# ── Create tool_definition_manifests table ───────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"tool_definition_manifests",
|
||||||
|
sa.Column("id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(64), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(128), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("category", sa.String(64), nullable=True),
|
||||||
|
sa.Column("interface_type", sa.String(16), nullable=False),
|
||||||
|
sa.Column("base_image", sa.String(256), nullable=True),
|
||||||
|
sa.Column("base_definition_id", sa.UUID(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"base_version", sa.String(32), nullable=False, server_default="latest"
|
||||||
|
),
|
||||||
|
sa.Column("manifest", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("dockerfile_cache", sa.Text(), nullable=True),
|
||||||
|
sa.Column("compose_cache", sa.Text(), nullable=True),
|
||||||
|
sa.Column("version", sa.String(32), nullable=False, server_default="v1"),
|
||||||
|
sa.Column("is_base", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("created_by_id", sa.UUID(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("name"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["base_definition_id"], ["tool_definition_manifests.id"]
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_id"], ["users.id"]),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"(base_image IS NOT NULL) OR (base_definition_id IS NOT NULL)",
|
||||||
|
name="ck_tool_definition_manifests_base_required",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Add columns to tool_types ────────────────────────────────────
|
||||||
|
# Check if manifest_id exists before adding
|
||||||
|
conn = op.get_bind()
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_types' AND column_name = 'manifest_id'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if not result.fetchone():
|
||||||
|
op.add_column("tool_types", sa.Column("manifest_id", sa.UUID(), nullable=True))
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_tool_types_manifest_id",
|
||||||
|
"tool_types",
|
||||||
|
"tool_definition_manifests",
|
||||||
|
["manifest_id"],
|
||||||
|
["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update definition_type to allow 'legacy' and 'manifest'
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT constraint_name FROM information_schema.check_constraints
|
||||||
|
WHERE constraint_name = 'chk_definition_type'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if result.fetchone():
|
||||||
|
op.drop_constraint("chk_definition_type", "tool_types", type_="check")
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE tool_types ALTER COLUMN definition_type TYPE VARCHAR(16)")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE tool_types ALTER COLUMN definition_type SET DEFAULT 'legacy'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Add columns to tool_instances ────────────────────────────────
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_instances' AND column_name = 'manifest_compiled_at'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if not result.fetchone():
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column(
|
||||||
|
"manifest_compiled_at", sa.TIMESTAMP(timezone=True), nullable=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_instances' AND column_name = 'image_tag'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if not result.fetchone():
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("image_tag", sa.String(256), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Data migration: create base definition + pi-agent manifest ───
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO tool_definition_manifests
|
||||||
|
(id, name, display_name, description, interface_type, base_image,
|
||||||
|
manifest, is_base, version, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(:base_id, 'ubuntu-24.04-dev', 'Ubuntu 24.04 Dev Base',
|
||||||
|
'Base development environment with build tools', 'terminal',
|
||||||
|
'ubuntu:24.04', :base_manifest, true, 'v1', now(), now())
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"base_id": BASE_UBUNTU_ID,
|
||||||
|
"base_manifest": json.dumps(
|
||||||
|
{
|
||||||
|
"name": "ubuntu-24.04-dev",
|
||||||
|
"display_name": "Ubuntu 24.04 Dev Base",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"packages": {
|
||||||
|
"apt": [
|
||||||
|
"curl",
|
||||||
|
"wget",
|
||||||
|
"git",
|
||||||
|
"build-essential",
|
||||||
|
"ca-certificates",
|
||||||
|
"python3",
|
||||||
|
"python3-pip",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"uid": 1000,
|
||||||
|
"gid": 1000,
|
||||||
|
"create_home": True,
|
||||||
|
"shell": "/bin/bash",
|
||||||
|
},
|
||||||
|
"env": {"DEBIAN_FRONTEND": "noninteractive"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO tool_definition_manifests
|
||||||
|
(id, name, display_name, description, category, interface_type,
|
||||||
|
base_definition_id, base_version, manifest, version, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(:manifest_id, 'pi-agent', 'Pi Agent',
|
||||||
|
'Terminal-based coding harness with nvim, ranger, tmux',
|
||||||
|
'development', 'terminal', :base_id, 'v1', :manifest, 'v1',
|
||||||
|
now(), now())
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"manifest_id": PI_AGENT_MANIFEST_ID,
|
||||||
|
"base_id": BASE_UBUNTU_ID,
|
||||||
|
"manifest": json.dumps(
|
||||||
|
{
|
||||||
|
"name": "pi-agent",
|
||||||
|
"display_name": "Pi Agent",
|
||||||
|
"description": "Terminal-based coding harness",
|
||||||
|
"category": "development",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"base_definition_id": str(BASE_UBUNTU_ID),
|
||||||
|
"base_version": "v1",
|
||||||
|
"packages": {
|
||||||
|
"apt": [
|
||||||
|
"neovim",
|
||||||
|
"ranger",
|
||||||
|
"tmux",
|
||||||
|
"htop",
|
||||||
|
"tree",
|
||||||
|
"jq",
|
||||||
|
],
|
||||||
|
"node": {"version": "20"},
|
||||||
|
"npm_global": ["@earendil-works/pi-coding-agent"],
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"uid": 1001,
|
||||||
|
"gid": 1001,
|
||||||
|
"create_home": True,
|
||||||
|
"shell": "/bin/bash",
|
||||||
|
},
|
||||||
|
"env": {"DEBIAN_FRONTEND": "noninteractive"},
|
||||||
|
"scripts": {
|
||||||
|
"build": [
|
||||||
|
"git config --global init.defaultBranch main && git config --global user.email 'dev@headquarter.local' && git config --global user.name 'Developer'",
|
||||||
|
"mkdir -p /home/user/.config/ranger && echo 'set preview_files true' > /home/user/.config/ranger/rc.conf",
|
||||||
|
],
|
||||||
|
"startup": [
|
||||||
|
"if [ -d /workspace ]; then sudo chown -R user:user /workspace 2>/dev/null || true; fi",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"mounts": [
|
||||||
|
{
|
||||||
|
"name": "workspace",
|
||||||
|
"target": "/workspace",
|
||||||
|
"source_type": "repo",
|
||||||
|
"writable": True,
|
||||||
|
"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": {
|
||||||
|
"command": ["/bin/bash"],
|
||||||
|
"stdin_open": True,
|
||||||
|
"tty": True,
|
||||||
|
"working_dir": "/workspace",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Update existing pi-agent tool_type ───────────────────────────
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET manifest_id = :manifest_id,
|
||||||
|
definition_type = 'manifest',
|
||||||
|
dockerfile_template = NULL,
|
||||||
|
compose_template = NULL
|
||||||
|
WHERE name = 'pi-agent'
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"manifest_id": PI_AGENT_MANIFEST_ID},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Restore pi-agent templates if manifest_id column exists
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_types' AND column_name = 'manifest_id'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
has_manifest_id = result.fetchone() is not None
|
||||||
|
|
||||||
|
if has_manifest_id:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET manifest_id = NULL,
|
||||||
|
definition_type = 'dockerfile',
|
||||||
|
dockerfile_template = :dockerfile,
|
||||||
|
compose_template = :compose
|
||||||
|
WHERE name = 'pi-agent'
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"dockerfile": """# Pi Coding Agent - Terminal-based coding harness
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \\
|
||||||
|
curl wget git neovim ranger tmux htop tree jq \\
|
||||||
|
ca-certificates python3 python3-pip build-essential \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\
|
||||||
|
&& apt-get install -y nodejs \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
RUN git config --global init.defaultBranch main \\
|
||||||
|
&& git config --global user.email "dev@headquarter.local" \\
|
||||||
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
|
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||||
|
|
||||||
|
RUN mkdir -p /home/user/.config/ranger \\
|
||||||
|
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||||
|
|
||||||
|
RUN mkdir -p /home/user/.pi/agent
|
||||||
|
|
||||||
|
USER user
|
||||||
|
|
||||||
|
CMD ["/bin/bash"]
|
||||||
|
""",
|
||||||
|
"compose": """services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
volumes:
|
||||||
|
- ${REPO_PATH}:/workspace
|
||||||
|
working_dir: /workspace
|
||||||
|
command: /bin/bash""",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop columns conditionally
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_instances' AND column_name = 'image_tag'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if result.fetchone():
|
||||||
|
op.drop_column("tool_instances", "image_tag")
|
||||||
|
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_instances' AND column_name = 'manifest_compiled_at'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if result.fetchone():
|
||||||
|
op.drop_column("tool_instances", "manifest_compiled_at")
|
||||||
|
|
||||||
|
if has_manifest_id:
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_tool_types_manifest_id", "tool_types", type_="foreignkey"
|
||||||
|
)
|
||||||
|
op.drop_column("tool_types", "manifest_id")
|
||||||
|
|
||||||
|
op.drop_table("tool_definition_manifests")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""drop tool_configs and config_folders tables
|
||||||
|
|
||||||
|
Revision ID: 2026_05_28_drop_tool_configs_and_config_folders
|
||||||
|
Revises: 2026_05_28_add_tool_definition_manifests
|
||||||
|
Create Date: 2026-05-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_28_drop_tool_configs_and_config_folders"
|
||||||
|
down_revision: Union[str, None] = "2026_05_28_add_terminal_sessions"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Drop tool_configs table if it exists
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_name = 'tool_configs'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if result.fetchone():
|
||||||
|
op.drop_table("tool_configs")
|
||||||
|
|
||||||
|
# Drop config_folders table if it exists
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_name = 'config_folders'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if result.fetchone():
|
||||||
|
op.drop_table("config_folders")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Recreate config_folders table
|
||||||
|
op.create_table(
|
||||||
|
"config_folders",
|
||||||
|
sa.Column("id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("mount_path", sa.String(1024), nullable=False),
|
||||||
|
sa.Column("files", sa.JSON(), default=dict, nullable=False),
|
||||||
|
sa.Column("project_overrides", sa.JSON(), default=dict, nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), default=True, nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recreate tool_configs table
|
||||||
|
op.create_table(
|
||||||
|
"tool_configs",
|
||||||
|
sa.Column("id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("tool_type_id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("project_id", sa.UUID(), nullable=True),
|
||||||
|
sa.Column("key", sa.String(255), nullable=False),
|
||||||
|
sa.Column("value", sa.Text(), nullable=False),
|
||||||
|
sa.Column("config_type", sa.String(20), default="env", nullable=False),
|
||||||
|
sa.Column("file_path", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("port_override", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("start_command", sa.Text(), nullable=True),
|
||||||
|
sa.Column("working_directory", sa.Text(), nullable=True),
|
||||||
|
sa.Column("environment_variables", sa.JSON(), default=dict, nullable=True),
|
||||||
|
sa.Column("volumes", sa.JSON(), default=list, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""add notifications table
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_add_notifications_table
|
||||||
|
Revises: 2026_05_28_add_monitoring_tables
|
||||||
|
Create Date: 2026-05-29
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_add_notifications_table"
|
||||||
|
down_revision: str | None = "2026_05_28_add_monitoring_tables"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"notifications",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("category", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("severity", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_type", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("source_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"metadata",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="{}",
|
||||||
|
),
|
||||||
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("dismissed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"],
|
||||||
|
["users.id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_notifications_user_created_at",
|
||||||
|
"notifications",
|
||||||
|
["user_id", sa.text("created_at DESC")],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_notifications_user_unread",
|
||||||
|
"notifications",
|
||||||
|
["user_id", "read_at"],
|
||||||
|
postgresql_where=sa.text("read_at IS NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_notifications_user_unread", table_name="notifications")
|
||||||
|
op.drop_index("idx_notifications_user_created_at", table_name="notifications")
|
||||||
|
op.drop_table("notifications")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""add_ssh_key_ids_to_tool_instances
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_add_ssh_key_ids_to_tool_instances
|
||||||
|
Revises: 2026_05_29_drop_ssh_key_id_from_config_profiles
|
||||||
|
Create Date: 2026-05-29 12:46:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "2026_05_29_add_ssh_key_ids_to_tool_instances"
|
||||||
|
down_revision = "2026_05_29_drop_ssh_key_id_from_config_profiles"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("ssh_key_ids", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "ssh_key_ids")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""drop_ssh_key_id_from_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_drop_ssh_key_id_from_config_profiles
|
||||||
|
Revises: 069d3da4dc9b
|
||||||
|
Create Date: 2026-05-29 12:45:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "2026_05_29_drop_ssh_key_id_from_config_profiles"
|
||||||
|
down_revision = "069d3da4dc9b"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("config_profiles", "ssh_key_id")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column(
|
||||||
|
"ssh_key_id",
|
||||||
|
sa.Uuid(),
|
||||||
|
sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""fix code-server bind-addr to host in DB template
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_fix_code_server_bind_addr
|
||||||
|
Revises: 2026_05_29_fix_web_tool_bind_address
|
||||||
|
Create Date: 2026-05-29 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_fix_code_server_bind_addr"
|
||||||
|
down_revision: str | None = "2026_05_29_fix_web_tool_bind_address"
|
||||||
|
branch_labels: Sequence[str] | None = None
|
||||||
|
depends_on: Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Find code-server tool types with broken --bind-addr in compose template
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_template
|
||||||
|
FROM tool_types
|
||||||
|
WHERE name = 'code-server'
|
||||||
|
AND compose_template LIKE '%--bind-addr%'
|
||||||
|
""")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for tool_id, compose_template in result:
|
||||||
|
updated = compose_template.replace(
|
||||||
|
"--bind-addr 0.0.0.0:8443", "--host 0.0.0.0"
|
||||||
|
).replace("--bind-addr", "--host 0.0.0.0")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET compose_template = :compose_template
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"compose_template": updated, "id": tool_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Fixed code-server template ({tool_id}): replaced --bind-addr with --host"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Fix code-server bind address to include port
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_fix_code_server_bind_addr_port
|
||||||
|
Revises: 2026_05_29_remove_lsio_command_override
|
||||||
|
Create Date: 2026-05-29 18:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_fix_code_server_bind_addr_port"
|
||||||
|
down_revision: Union[str, None] = "2026_05_29_remove_lsio_command_override"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_tool_type_templates(conn) -> None:
|
||||||
|
"""Fix code-server tool type templates with broken --host override."""
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_template, default_port
|
||||||
|
FROM tool_types
|
||||||
|
WHERE name = 'code-server'
|
||||||
|
AND compose_template LIKE '%--host%'
|
||||||
|
""")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for tool_id, compose_template, default_port in result:
|
||||||
|
port = default_port or 8443
|
||||||
|
expected = f"--bind-addr 0.0.0.0:{port}"
|
||||||
|
|
||||||
|
# Replace any line containing --host with the correct bind-addr
|
||||||
|
lines = compose_template.split("\n")
|
||||||
|
new_lines = []
|
||||||
|
modified = False
|
||||||
|
for line in lines:
|
||||||
|
if "command:" in line and "--host" in line:
|
||||||
|
indent = line[: len(line) - len(line.lstrip())]
|
||||||
|
new_lines.append(f"{indent}command: {expected}")
|
||||||
|
modified = True
|
||||||
|
else:
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
if not modified:
|
||||||
|
continue
|
||||||
|
|
||||||
|
updated = "\n".join(new_lines)
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET compose_template = :compose_template
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"compose_template": updated, "id": tool_id},
|
||||||
|
)
|
||||||
|
print(f"Fixed code-server template ({tool_id}): replaced --host with {expected}")
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_instance_compose_files(conn) -> None:
|
||||||
|
"""Fix existing instance compose files on disk with broken --host override."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Use information_schema to check if compose_path column exists
|
||||||
|
col_result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_instances'
|
||||||
|
AND column_name = 'compose_path'
|
||||||
|
""")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not col_result:
|
||||||
|
print("compose_path column not found, skipping instance file fixes")
|
||||||
|
return
|
||||||
|
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_path, tool_type_id
|
||||||
|
FROM tool_instances
|
||||||
|
WHERE compose_path IS NOT NULL
|
||||||
|
""")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for instance_id, compose_path, tool_type_id in result:
|
||||||
|
path = Path(compose_path)
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = path.read_text()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "--host" not in content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get default_port from tool_type
|
||||||
|
port_result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT default_port FROM tool_types WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"id": tool_type_id},
|
||||||
|
).fetchone()
|
||||||
|
port = port_result[0] if port_result and port_result[0] else 8443
|
||||||
|
expected = f"--bind-addr 0.0.0.0:{port}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(content)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not data or "services" not in data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
modified = False
|
||||||
|
for svc in data["services"].values():
|
||||||
|
if "command" in svc:
|
||||||
|
cmd = svc["command"]
|
||||||
|
if "--host" in cmd:
|
||||||
|
svc["command"] = expected
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
if not modified:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
path.write_text(yaml.dump(data, default_flow_style=False))
|
||||||
|
print(
|
||||||
|
f"Fixed code-server instance compose ({instance_id}): "
|
||||||
|
f"replaced --host with {expected}"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Failed to fix instance {instance_id}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
_fix_tool_type_templates(conn)
|
||||||
|
_fix_instance_compose_files(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""fix web tool bind address to 0.0.0.0
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_fix_web_tool_bind_address
|
||||||
|
Revises: 2026_05_29_remove_ssh_keys_mount_from_manifest
|
||||||
|
Create Date: 2026-05-29 14:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_fix_web_tool_bind_address"
|
||||||
|
down_revision: Union[str, None] = "2026_05_29_remove_ssh_keys_mount_from_manifest"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_code_server_compose(conn) -> None:
|
||||||
|
"""Update code-server compose template to bind to 0.0.0.0."""
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_template, definition_type
|
||||||
|
FROM tool_types
|
||||||
|
WHERE name = 'code-server'
|
||||||
|
""")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
tool_id, compose_template, definition_type = result
|
||||||
|
|
||||||
|
if definition_type != "compose" or not compose_template:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fix or add command to bind to 0.0.0.0
|
||||||
|
lines = compose_template.split("\n")
|
||||||
|
new_lines = []
|
||||||
|
image_line_idx = -1
|
||||||
|
command_fixed = False
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
# Replace broken --bind-addr with correct --host
|
||||||
|
if "command:" in line and "--bind-addr" in line:
|
||||||
|
indent = line[: len(line) - len(line.lstrip())]
|
||||||
|
new_lines.append(f"{indent}command: --host 0.0.0.0")
|
||||||
|
command_fixed = True
|
||||||
|
continue
|
||||||
|
new_lines.append(line)
|
||||||
|
if "image:" in line and image_line_idx == -1:
|
||||||
|
image_line_idx = i
|
||||||
|
|
||||||
|
# If no command line exists, insert one after image
|
||||||
|
if not command_fixed and image_line_idx != -1:
|
||||||
|
image_line = lines[image_line_idx]
|
||||||
|
indent = image_line[: len(image_line) - len(image_line.lstrip())]
|
||||||
|
# Insert after the image line in new_lines
|
||||||
|
insert_idx = new_lines.index(image_line) + 1
|
||||||
|
new_lines.insert(insert_idx, f"{indent}command: --host 0.0.0.0")
|
||||||
|
command_fixed = True
|
||||||
|
|
||||||
|
if not command_fixed:
|
||||||
|
return
|
||||||
|
|
||||||
|
updated_compose = "\n".join(new_lines)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET compose_template = :compose_template
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"compose_template": updated_compose, "id": tool_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Updated code-server tool type ({tool_id}) to bind to 0.0.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_jupyter_compose(conn) -> None:
|
||||||
|
"""Update jupyter-notebook compose template to bind to 0.0.0.0."""
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_template, definition_type
|
||||||
|
FROM tool_types
|
||||||
|
WHERE name = 'jupyter-notebook'
|
||||||
|
""")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
tool_id, compose_template, definition_type = result
|
||||||
|
|
||||||
|
if definition_type != "compose" or not compose_template:
|
||||||
|
return
|
||||||
|
|
||||||
|
if "command:" in compose_template:
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = compose_template.split("\n")
|
||||||
|
new_lines = []
|
||||||
|
image_line_idx = -1
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
new_lines.append(line)
|
||||||
|
if "image:" in line and image_line_idx == -1:
|
||||||
|
image_line_idx = i
|
||||||
|
indent = line[: len(line) - len(line.lstrip())]
|
||||||
|
# Jupyter needs --ip=0.0.0.0 to bind to all interfaces
|
||||||
|
new_lines.append(
|
||||||
|
f"{indent}command: start-notebook.sh --ip=0.0.0.0 --port=8888 --no-browser"
|
||||||
|
)
|
||||||
|
|
||||||
|
if image_line_idx == -1:
|
||||||
|
return
|
||||||
|
|
||||||
|
updated_compose = "\n".join(new_lines)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET compose_template = :compose_template
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"compose_template": updated_compose, "id": tool_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Updated jupyter-notebook tool type ({tool_id}) to bind to 0.0.0.0:8888")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
_fix_code_server_compose(conn)
|
||||||
|
_fix_jupyter_compose(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Cannot safely downgrade without knowing the original compose_template
|
||||||
|
pass
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Remove broken command override from LSIO code-server templates
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_remove_lsio_command_override
|
||||||
|
Revises: 2026_05_29_fix_code_server_bind_addr
|
||||||
|
Create Date: 2026-05-29 15:05:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_remove_lsio_command_override"
|
||||||
|
down_revision: str | None = "2026_05_29_fix_code_server_bind_addr"
|
||||||
|
branch_labels: Sequence[str] | None = None
|
||||||
|
depends_on: Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Fix tool_types templates in DB
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_template
|
||||||
|
FROM tool_types
|
||||||
|
WHERE name = 'code-server'
|
||||||
|
""")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
for tool_id, compose_template in result:
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(compose_template)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not data or "services" not in data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
modified = False
|
||||||
|
for svc in data["services"].values():
|
||||||
|
image = svc.get("image", "")
|
||||||
|
if not image or "linuxserver" not in image:
|
||||||
|
continue
|
||||||
|
if "command" in svc:
|
||||||
|
cmd = svc["command"]
|
||||||
|
if "--bind-addr" in cmd or "--host" in cmd:
|
||||||
|
del svc["command"]
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
if modified:
|
||||||
|
updated = yaml.dump(data, default_flow_style=False)
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET compose_template = :compose_template
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"compose_template": updated, "id": tool_id},
|
||||||
|
)
|
||||||
|
print(f"Removed broken command override from LSIO template ({tool_id})")
|
||||||
|
|
||||||
|
# Fix existing instance compose files on disk
|
||||||
|
# Use information_schema to check if compose_path column exists
|
||||||
|
col_result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_instances'
|
||||||
|
AND column_name = 'compose_path'
|
||||||
|
""")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if col_result:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_path
|
||||||
|
FROM tool_instances
|
||||||
|
WHERE compose_path IS NOT NULL
|
||||||
|
""")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for instance_id, compose_path in result:
|
||||||
|
path = Path(compose_path)
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = path.read_text()
|
||||||
|
data = yaml.safe_load(content)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not data or "services" not in data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
modified = False
|
||||||
|
for svc in data["services"].values():
|
||||||
|
image = svc.get("image", "")
|
||||||
|
if not image or "linuxserver" not in image:
|
||||||
|
continue
|
||||||
|
if "command" in svc:
|
||||||
|
cmd = svc["command"]
|
||||||
|
if "--bind-addr" in cmd or "--host" in cmd:
|
||||||
|
del svc["command"]
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
if modified:
|
||||||
|
path.write_text(yaml.dump(data, default_flow_style=False))
|
||||||
|
print(
|
||||||
|
f"Removed broken command override from instance compose "
|
||||||
|
f"({instance_id})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""remove ssh_keys mount from pi-agent manifest
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_remove_ssh_keys_mount_from_manifest
|
||||||
|
Revises: 2026_05_29_add_ssh_key_ids_to_tool_instances
|
||||||
|
Create Date: 2026-05-29 14:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_remove_ssh_keys_mount_from_manifest"
|
||||||
|
down_revision: Union[str, None] = "2026_05_29_add_ssh_key_ids_to_tool_instances"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Remove the ssh_keys mount from the pi-agent manifest."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Get the pi-agent manifest
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id, manifest FROM tool_definition_manifests WHERE name = 'pi-agent'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
|
||||||
|
manifest_id, manifest_json = row
|
||||||
|
manifest = (
|
||||||
|
manifest_json if isinstance(manifest_json, dict) else json.loads(manifest_json)
|
||||||
|
)
|
||||||
|
|
||||||
|
mounts = manifest.get("mounts", [])
|
||||||
|
original_count = len(mounts)
|
||||||
|
|
||||||
|
# Remove any mount named "ssh_keys"
|
||||||
|
filtered_mounts = [m for m in mounts if m.get("name") != "ssh_keys"]
|
||||||
|
|
||||||
|
if len(filtered_mounts) < original_count:
|
||||||
|
manifest["mounts"] = filtered_mounts
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE tool_definition_manifests SET manifest = :manifest WHERE id = :id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"manifest": json.dumps(manifest),
|
||||||
|
"id": manifest_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Restore the ssh_keys mount to the pi-agent manifest."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id, manifest FROM tool_definition_manifests WHERE name = 'pi-agent'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
|
||||||
|
manifest_id, manifest_json = row
|
||||||
|
manifest = (
|
||||||
|
manifest_json if isinstance(manifest_json, dict) else json.loads(manifest_json)
|
||||||
|
)
|
||||||
|
|
||||||
|
mounts = manifest.get("mounts", [])
|
||||||
|
|
||||||
|
# Check if ssh_keys mount already exists
|
||||||
|
if any(m.get("name") == "ssh_keys" for m in mounts):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add the ssh_keys mount back
|
||||||
|
mounts.append(
|
||||||
|
{
|
||||||
|
"name": "ssh_keys",
|
||||||
|
"target": "/home/user/.ssh",
|
||||||
|
"source_type": "ssh_key",
|
||||||
|
"mode": "0700",
|
||||||
|
"file_mode": "0600",
|
||||||
|
"readonly": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
manifest["mounts"] = mounts
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE tool_definition_manifests SET manifest = :manifest WHERE id = :id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"manifest": json.dumps(manifest),
|
||||||
|
"id": manifest_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""add workspaces table
|
||||||
|
|
||||||
|
Revision ID: 2026_06_01_add_workspaces
|
||||||
|
Revises: 2026_05_29_fix_code_server_bind_addr_port
|
||||||
|
Create Date: 2026-06-01 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_06_01_add_workspaces"
|
||||||
|
down_revision: str | None = "2026_05_29_fix_code_server_bind_addr_port"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create workspaces table
|
||||||
|
op.create_table(
|
||||||
|
"workspaces",
|
||||||
|
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"repo_id",
|
||||||
|
sa.Uuid(as_uuid=True),
|
||||||
|
sa.ForeignKey("git_repositories.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Uuid(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("branch", sa.String(255), nullable=False, server_default="main"),
|
||||||
|
sa.Column("path", sa.String(2048), nullable=False),
|
||||||
|
sa.Column("status", sa.String(16), nullable=False, server_default="ready"),
|
||||||
|
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
|
||||||
|
if_not_exists=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index("idx_workspaces_repo_id", "workspaces", ["repo_id"])
|
||||||
|
op.create_index("idx_workspaces_user_id", "workspaces", ["user_id"])
|
||||||
|
op.create_index("idx_workspaces_status", "workspaces", ["status"])
|
||||||
|
|
||||||
|
# Add workspace_id to tool_instances
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column(
|
||||||
|
"workspace_id",
|
||||||
|
sa.Uuid(as_uuid=True),
|
||||||
|
sa.ForeignKey("workspaces.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_tool_instances_workspace_id", "tool_instances", ["workspace_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_tool_instances_workspace_id", table_name="tool_instances")
|
||||||
|
op.drop_column("tool_instances", "workspace_id")
|
||||||
|
op.drop_table("workspaces")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""merge_remove_is_builtin_and_add_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 6fc7bfcf199f
|
||||||
|
Revises: 2026_05_23_remove_is_builtin, 2026_05_24_add_config_profiles
|
||||||
|
Create Date: 2026-05-24 18:00:43.990361
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '6fc7bfcf199f'
|
||||||
|
down_revision = ('2026_05_23_remove_is_builtin', '2026_05_24_add_config_profiles')
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -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,23 @@
|
|||||||
|
"""merge_single_interface_and_clone_mode
|
||||||
|
|
||||||
|
Revision ID: f3d2dc90ba3a
|
||||||
|
Revises: 0015_single_interface, 2026_05_22_add_clone_mode
|
||||||
|
Create Date: 2026-05-24 10:43:14.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "f3d2dc90ba3a"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ("0015_single_interface", "2026_05_22_add_clone_mode")
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# apps/api/src (index)
|
||||||
|
dir: apps/api/src
|
||||||
|
|
||||||
|
## role
|
||||||
|
Core application package that bootstraps and configures the Headquarter API FastAPI service with its infrastructure concerns.
|
||||||
|
## parent
|
||||||
|
index: apps/api/.pi-map.index.md
|
||||||
|
map: apps/api/.pi-map.md
|
||||||
|
## children
|
||||||
|
- apps/api/src/api
|
||||||
|
index: apps/api/src/api/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/.pi-map.md
|
||||||
|
- apps/api/src/auth
|
||||||
|
index: apps/api/src/auth/.pi-map.index.md
|
||||||
|
map: apps/api/src/auth/.pi-map.md
|
||||||
|
- apps/api/src/headquarter_api.egg-info
|
||||||
|
index: apps/api/src/headquarter_api.egg-info/.pi-map.index.md
|
||||||
|
map: apps/api/src/headquarter_api.egg-info/.pi-map.md
|
||||||
|
- apps/api/src/models
|
||||||
|
index: apps/api/src/models/.pi-map.index.md
|
||||||
|
map: apps/api/src/models/.pi-map.md
|
||||||
|
- apps/api/src/schemas
|
||||||
|
index: apps/api/src/schemas/.pi-map.index.md
|
||||||
|
map: apps/api/src/schemas/.pi-map.md
|
||||||
|
- apps/api/src/scripts
|
||||||
|
index: apps/api/src/scripts/.pi-map.index.md
|
||||||
|
map: apps/api/src/scripts/.pi-map.md
|
||||||
|
- apps/api/src/seeds
|
||||||
|
index: apps/api/src/seeds/.pi-map.index.md
|
||||||
|
map: apps/api/src/seeds/.pi-map.md
|
||||||
|
- apps/api/src/services
|
||||||
|
index: apps/api/src/services/.pi-map.index.md
|
||||||
|
map: apps/api/src/services/.pi-map.md
|
||||||
|
- apps/api/src/utils
|
||||||
|
index: apps/api/src/utils/.pi-map.index.md
|
||||||
|
map: apps/api/src/utils/.pi-map.md
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- config.py
|
||||||
|
- database.py
|
||||||
|
- logging_config.py
|
||||||
|
- main.py
|
||||||
|
## links
|
||||||
|
index: apps/api/src/.pi-map.index.md
|
||||||
|
map: apps/api/src/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change src behavior
|
||||||
|
read: __init__.py, config.py, database.py
|
||||||
|
- change src config
|
||||||
|
read: config.py, logging_config.py
|
||||||
|
- explore src subdirectories
|
||||||
|
index: apps/api/src/api/.pi-map.index.md, apps/api/src/auth/.pi-map.index.md, apps/api/src/headquarter_api.egg-info/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# apps/api/src
|
||||||
|
dir: apps/api/src
|
||||||
|
|
||||||
|
index: apps/api/src/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Core application package that bootstraps and configures the Headquarter API FastAPI service with its infrastructure concerns.
|
||||||
|
## files
|
||||||
|
- __init__.py | Marks the directory as a Python package for the Headquarter API.
|
||||||
|
- config.py | Defines application configuration settings using Pydantic with environment variable loading, database URL construction, and computed properties for service URLs and OAuth endpoints. | 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 async SQLAlchemy database engine and session factory, and provides retry logic for database initialization with 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:logger.debug, call:logger.error, call:asyncio.sleep, call:str(exc).lower, call:logger.warning | dep: asyncio, logging, subprocess, sqlalchemy.ext.asyncio, sqlalchemy.pool, src.config, sqlalchemy
|
||||||
|
- logging_config.py | Configures structured JSON logging with correlation ID injection, custom formatters, and HTTP request/exception middleware for a FastAPI application. | exp: class:CorrelationIdFilter, method:filter(self, record: logging.LogRecord) → bool, call:get_correlation_id, class:JSONFormatter, method:format(self, record: logging.LogRecord) → str, call:self.formatTime, call:record.getMessage, call:getattr, call:self.formatException, call:json.dumps, method:formatTime(self, record: logging.LogRecord, datefmt) → str, call:time.strftime, call:time.gmtime, class:RequestLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:time.time, call:logger.info, call:call_next, call:int, call:logger.error, call:type, call:traceback.format_exc, class:ExceptionLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:call_next, call:logger.critical, call:traceback.format_exc, func:configure_logging(level) → None, call:JSONFormatter, call:logging.StreamHandler, call:console_handler.setFormatter, call:console_handler.addFilter, call:CorrelationIdFilter, call:root_logger.setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("sqlalchemy.engine").setLevel, call:logger.info, call:logging.getLevelName | dep: json, logging, sys, time, traceback, collections.abc, fastapi, starlette.middleware.base, src.services.shared.correlation
|
||||||
|
- main.py | Initializes and configures a FastAPI application for the "Headquarter API" with database setup, middleware, routing, and background services. | exp: func:_sanitize_validation_errors(errors), call:error.get, call:str, call:ctx.items, call:isinstance, call:type, call:sanitized.append, func:validation_exception_handler(request: Request, exc: RequestValidationError), call:exc.errors, call:logger.warning, call:_sanitize_validation_errors, call:JSONResponse, func:on_startup(), call:logger.info, call:init_database, call:logger.error, call:sys.exit, call:_health_monitor.start, call:seed_builtin_tool_types, func:on_shutdown(), call:logger.info, call:_health_monitor.stop | dep: logging, os, fastapi, fastapi.exceptions, fastapi.middleware.cors, fastapi.responses, fastapi.staticfiles, src.api.config, src.api.project, src.api.system, src.api.tool, src.api.user, src.api.workspace, src.config, src.models, src.database, src.logging_config, src.seeds.builtin_tool_types, src.services.instance, src.services.shared, sys, src.api.*
|
||||||
|
## arch
|
||||||
|
Layered configuration with Pydantic settings, async SQLAlchemy with Alembic migration integration, structured JSON logging with correlation ID tracking, and FastAPI middleware/routing setup.
|
||||||
|
## tags
|
||||||
|
src, database, logging, call:logger.info, api, middleware, fastapi, filter
|
||||||
|
## symbols
|
||||||
|
- Settings
|
||||||
|
- CorrelationIdFilter
|
||||||
|
- JSONFormatter
|
||||||
|
- RequestLoggingMiddleware
|
||||||
|
- ExceptionLoggingMiddleware
|
||||||
|
- build_database_url
|
||||||
|
- init_database
|
||||||
|
- filter
|
||||||
|
## workflows
|
||||||
|
- change src behavior
|
||||||
|
read: __init__.py, config.py, database.py
|
||||||
|
- change src config
|
||||||
|
read: config.py, logging_config.py
|
||||||
|
- explore src subdirectories
|
||||||
|
index: apps/api/src/api/.pi-map.index.md, apps/api/src/auth/.pi-map.index.md, apps/api/src/headquarter_api.egg-info/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# apps/api/src/api (index)
|
||||||
|
dir: apps/api/src/api
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines reusable API validation utilities and package structure for FastAPI router organization.
|
||||||
|
## parent
|
||||||
|
index: apps/api/src/.pi-map.index.md
|
||||||
|
map: apps/api/src/.pi-map.md
|
||||||
|
## children
|
||||||
|
- apps/api/src/api/config
|
||||||
|
index: apps/api/src/api/config/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/config/.pi-map.md
|
||||||
|
- apps/api/src/api/project
|
||||||
|
index: apps/api/src/api/project/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/project/.pi-map.md
|
||||||
|
- apps/api/src/api/system
|
||||||
|
index: apps/api/src/api/system/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/system/.pi-map.md
|
||||||
|
- apps/api/src/api/tool
|
||||||
|
index: apps/api/src/api/tool/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/tool/.pi-map.md
|
||||||
|
- apps/api/src/api/user
|
||||||
|
index: apps/api/src/api/user/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/user/.pi-map.md
|
||||||
|
- apps/api/src/api/workspace
|
||||||
|
index: apps/api/src/api/workspace/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/workspace/.pi-map.md
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- shared_validators.py
|
||||||
|
## links
|
||||||
|
index: apps/api/src/api/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change api behavior
|
||||||
|
read: __init__.py, shared_validators.py
|
||||||
|
- explore api subdirectories
|
||||||
|
index: apps/api/src/api/config/.pi-map.index.md, apps/api/src/api/project/.pi-map.index.md, apps/api/src/api/system/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# apps/api/src/api
|
||||||
|
dir: apps/api/src/api
|
||||||
|
|
||||||
|
index: apps/api/src/api/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines reusable API validation utilities and package structure for FastAPI router organization.
|
||||||
|
## files
|
||||||
|
- __init__.py | Marks the directory as a Python package for API routers.
|
||||||
|
- shared_validators.py | Provides reusable Pydantic validator functions for API schema validation including mount paths, files, environment variables, and volume mounts. | exp: func:validate_mount_path(v: str | None) → str | None, call:v.startswith, raise:ValueError, func:validate_files(v: dict | None, max_size_bytes) → dict | None, call:v.items, call:path.startswith, call:len, call:content.encode, raise:ValueError, func:validate_env_vars(v: dict | None) → dict | None, call:isinstance, raise:ValueError, func:validate_volumes(v: list | None) → list | None, call:isinstance, call:enumerate, raise:ValueError
|
||||||
|
## arch
|
||||||
|
Modular utility package with shared Pydantic validators for cross-cutting API schema concerns, following separation of validation logic from route handlers.
|
||||||
|
## tags
|
||||||
|
validate, raise:value, error, call:isinstance, mount, api, init, path
|
||||||
|
## symbols
|
||||||
|
- validate_mount_path
|
||||||
|
- validate_files
|
||||||
|
- validate_env_vars
|
||||||
|
- validate_volumes
|
||||||
|
- call:v.startswith
|
||||||
|
- raise:ValueError
|
||||||
|
- call:v.items
|
||||||
|
- call:path.startswith
|
||||||
|
## workflows
|
||||||
|
- change api behavior
|
||||||
|
read: __init__.py, shared_validators.py
|
||||||
|
- explore api subdirectories
|
||||||
|
index: apps/api/src/api/config/.pi-map.index.md, apps/api/src/api/project/.pi-map.index.md, apps/api/src/api/system/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -1,4 +1 @@
|
|||||||
from src.api.auth import router as auth_router
|
"""API routers package."""
|
||||||
from src.api.users import router as users_router
|
|
||||||
|
|
||||||
__all__ = ["auth_router", "users_router"]
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# apps/api/src/api/config (index)
|
||||||
|
dir: apps/api/src/api/config
|
||||||
|
|
||||||
|
## role
|
||||||
|
Exposes REST API endpoints for managing user configuration profiles and settings.
|
||||||
|
## 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
|
||||||
|
Exposes REST API endpoints for managing user configuration profiles and settings.
|
||||||
|
## files
|
||||||
|
- __init__.py | Aggregates and exports configuration-related API routers for the config module. | dep: src.api.config.config_profiles, src.api.config.user_config
|
||||||
|
- config_profiles.py | FastAPI router providing CRUD endpoints for user config profiles with includes, resolution, defaults, and git URL validation | exp: func:list_config_profiles(project_id, tool_type_id, current_user_id, session), call:select(ConfigProfile) .where(ConfigProfile.user_id == user_uuid) .options, call:selectinload, call:uuid.UUID, call:conditions.append, call:ConfigProfile.project_id.is_, call:ConfigProfile.tool_type_id.is_, call:query.where, call:or_, call:session.execute, call:result.scalars().all, call:profile_to_response, func:create_config_profile(data: ConfigProfileCreate, current_user_id, session), call:create_profile, call:logger.debug, call:profile_to_response, func:get_config_profile(profile_id: str, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:profile_to_response, raise:HTTPException, func:update_config_profile(profile_id: str, data: ConfigProfileUpdate, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:update_profile, call:logger.debug, call:profile_to_response, raise:HTTPException, func:delete_config_profile(profile_id: str, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:session.delete, call:session.commit, call:logger.debug, raise:HTTPException, func:update_profile_includes_endpoint(profile_id: str, data: ConfigProfileIncludeUpdate, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:update_includes, call:session.execute, call:select(ConfigProfileInclude).where, call:inc_result.scalars().all, call:logger.debug, call:profile_to_response, call:list, raise:HTTPException, func:preview_config_profile(profile_id: str, current_user_id, session), call:get_profile_with_includes, call:uuid.UUID, call:resolve_profile, call:resolved_profile_to_dict, raise:HTTPException, func:resolve_default_profile_endpoint(project_id, tool_type_id, current_user_id, session), call:resolve_default_profile, call:uuid.UUID, func:get_default_profiles_endpoint(user_id, session) → dict, call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, func:set_default_profiles_endpoint(data: DefaultProfilesUpdate, user_id, session) → dict, call:validate_default_profiles, call:get_or_create_user_config, call:session.commit, call:session.refresh, func:get_default_profile_for_tool_type_endpoint(tool_type_id: str, user_id, session) → dict, call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:user_config.default_profiles.get, func:validate_git_url_endpoint(data: ValidateGitUrlRequest, current_user_id, session) → ValidateGitUrlResponse, call:validate_git_url | dep: logging, uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, sqlalchemy.orm, src.auth.dependencies, src.models, src.schemas.config, src.services.config.config_profile_resolver, src.services.config.crud_service, src.services.config.resolver_service
|
||||||
|
- user_config.py | Provides FastAPI endpoints to get and update the current user's configuration settings, creating a default config if none exists. | exp: func:_get_or_create_config(session: AsyncSession, user_id: uuid.UUID) → UserConfig, call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:UserConfig, call:session.add, call:session.commit, call:session.refresh, func:get_user_config(user_id, session) → UserConfigResponse, call:_get_user, call:_get_or_create_config, call:UserConfigResponse.model_validate, func:update_user_config(data: UserConfigUpdate, user_id, session) → UserConfigResponse, call:_get_user, call:_get_or_create_config, call:data.model_dump, call:logger.debug, call:session.commit, call:session.refresh, call:UserConfigResponse.model_validate | dep: logging, uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.schemas.user
|
||||||
|
## arch
|
||||||
|
FastAPI router modules following domain-driven separation with CRUD patterns, validation logic, and automatic default initialization.
|
||||||
|
## tags
|
||||||
|
config, profile, get, user, includes, call:uuid.uuid, or, default
|
||||||
|
## symbols
|
||||||
|
- list_config_profiles
|
||||||
|
- create_config_profile
|
||||||
|
- get_config_profile
|
||||||
|
- update_config_profile
|
||||||
|
- delete_config_profile
|
||||||
|
- update_profile_includes_endpoint
|
||||||
|
- preview_config_profile
|
||||||
|
- resolve_default_profile_endpoint
|
||||||
|
## workflows
|
||||||
|
- change config behavior
|
||||||
|
read: __init__.py, config_profiles.py, user_config.py
|
||||||
|
- change config config
|
||||||
|
read: config_profiles.py, user_config.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Config API routers module."""
|
||||||
|
|
||||||
|
from src.api.config.config_profiles import router as config_profiles_router
|
||||||
|
from src.api.config.user_config import router as user_config_router
|
||||||
|
|
||||||
|
__all__ = ["config_profiles_router", "user_config_router"]
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Config profile API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
|
||||||
|
from src.schemas.config import (
|
||||||
|
ConfigProfileCreate,
|
||||||
|
ConfigProfileIncludeUpdate,
|
||||||
|
ConfigProfileResponse,
|
||||||
|
ConfigProfileUpdate,
|
||||||
|
DefaultProfilesUpdate,
|
||||||
|
ValidateGitUrlRequest,
|
||||||
|
ValidateGitUrlResponse,
|
||||||
|
)
|
||||||
|
from src.services.config.config_profile_resolver import (
|
||||||
|
ConfigProfileCycleError,
|
||||||
|
resolve_profile,
|
||||||
|
resolved_profile_to_dict,
|
||||||
|
)
|
||||||
|
from src.services.config.crud_service import (
|
||||||
|
create_profile,
|
||||||
|
get_or_create_user_config,
|
||||||
|
get_profile_with_includes,
|
||||||
|
profile_to_response,
|
||||||
|
update_includes,
|
||||||
|
update_profile,
|
||||||
|
validate_default_profiles,
|
||||||
|
)
|
||||||
|
from src.services.config.resolver_service import (
|
||||||
|
resolve_default_profile,
|
||||||
|
validate_git_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ConfigProfileResponse])
|
||||||
|
async def list_config_profiles(
|
||||||
|
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||||
|
tool_type_id: str | None = Query(
|
||||||
|
None, description="Filter by tool type compatibility"
|
||||||
|
),
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""List config profiles, optionally filtered by compatibility."""
|
||||||
|
user_uuid = current_user_id
|
||||||
|
query = (
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.user_id == user_uuid)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
|
||||||
|
if project_id or tool_type_id:
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
project_uuid = uuid.UUID(project_id) if project_id else None
|
||||||
|
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
||||||
|
|
||||||
|
conditions: list = []
|
||||||
|
conditions.append(
|
||||||
|
(ConfigProfile.project_id.is_(None))
|
||||||
|
& (ConfigProfile.tool_type_id.is_(None))
|
||||||
|
)
|
||||||
|
if project_uuid:
|
||||||
|
conditions.append(ConfigProfile.project_id == project_uuid)
|
||||||
|
if tool_uuid:
|
||||||
|
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
if project_uuid and tool_uuid:
|
||||||
|
conditions.append(
|
||||||
|
(ConfigProfile.project_id == project_uuid)
|
||||||
|
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.where(or_(*conditions))
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
profiles = result.scalars().all()
|
||||||
|
return [profile_to_response(p) for p in profiles]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_config_profile(
|
||||||
|
data: ConfigProfileCreate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Create a new config profile."""
|
||||||
|
profile = await create_profile(session, current_user_id, data)
|
||||||
|
logger.debug("Created config profile %s for user %s", profile.id, current_user_id)
|
||||||
|
return profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
|
async def get_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Get a config profile by ID."""
|
||||||
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
return profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
|
async def update_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
data: ConfigProfileUpdate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Update a config profile."""
|
||||||
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = await update_profile(session, profile, data)
|
||||||
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
|
return profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Delete a config profile."""
|
||||||
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.delete(profile)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.debug("Deleted config profile %s", profile_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
||||||
|
async def update_profile_includes_endpoint(
|
||||||
|
profile_id: str,
|
||||||
|
data: ConfigProfileIncludeUpdate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Update the ordered includes for a config profile."""
|
||||||
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||||
|
profile = await update_includes(session, profile, included_uuids, current_user_id)
|
||||||
|
|
||||||
|
inc_result = await session.execute(
|
||||||
|
select(ConfigProfileInclude).where(
|
||||||
|
ConfigProfileInclude.profile_id == profile.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
direct_includes = inc_result.scalars().all()
|
||||||
|
|
||||||
|
logger.debug("Updated includes for config profile %s", profile.id)
|
||||||
|
return profile_to_response(profile, list(direct_includes))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{profile_id}/preview")
|
||||||
|
async def preview_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Preview the resolved output of a config profile."""
|
||||||
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = await resolve_profile(session, profile.id)
|
||||||
|
except ConfigProfileCycleError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
return resolved_profile_to_dict(resolved)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults/resolve")
|
||||||
|
async def resolve_default_profile_endpoint(
|
||||||
|
project_id: str = Query(..., description="Project ID"),
|
||||||
|
tool_type_id: str = Query(..., description="Tool type ID"),
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Resolve the default config profile for a project/tool combination."""
|
||||||
|
return await resolve_default_profile(
|
||||||
|
session,
|
||||||
|
current_user_id,
|
||||||
|
uuid.UUID(project_id),
|
||||||
|
uuid.UUID(tool_type_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults")
|
||||||
|
async def get_default_profiles_endpoint(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get all default profile mappings for the current user."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
|
user_config = result.scalar_one_or_none()
|
||||||
|
return {"default_profiles": user_config.default_profiles if user_config else {}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/defaults")
|
||||||
|
async def set_default_profiles_endpoint(
|
||||||
|
data: DefaultProfilesUpdate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Set default profile mappings for the current user."""
|
||||||
|
await validate_default_profiles(session, user_id, data.default_profiles)
|
||||||
|
user_config = await get_or_create_user_config(session, user_id)
|
||||||
|
user_config.config = {
|
||||||
|
**user_config.config,
|
||||||
|
"default_profiles": data.default_profiles,
|
||||||
|
}
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user_config)
|
||||||
|
return {"default_profiles": user_config.default_profiles}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults/{tool_type_id}")
|
||||||
|
async def get_default_profile_for_tool_type_endpoint(
|
||||||
|
tool_type_id: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get the default profile ID for a specific tool type."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
|
user_config = result.scalar_one_or_none()
|
||||||
|
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
|
||||||
|
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
||||||
|
async def validate_git_url_endpoint(
|
||||||
|
data: ValidateGitUrlRequest,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> ValidateGitUrlResponse:
|
||||||
|
"""Validate a git remote URL and list available branches."""
|
||||||
|
return await validate_git_url(session, current_user_id, data.url, data.ssh_key_id)
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends
|
||||||
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_current_user, 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 import UserConfig
|
||||||
from src.models.user_config import UserConfig
|
from src.schemas.user import UserConfigResponse, UserConfigUpdate
|
||||||
from src.schemas.user_config import UserConfigResponse, UserConfigUpdate
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_config(
|
||||||
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
session: AsyncSession, user_id: uuid.UUID
|
||||||
|
) -> UserConfig:
|
||||||
"""Get or create user config record.
|
"""Get or create user config record.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -24,10 +26,12 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
|
|||||||
Returns:
|
Returns:
|
||||||
The user's config, creating a new one if it doesn't exist.
|
The user's config, creating a new one if it doesn't exist.
|
||||||
"""
|
"""
|
||||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user.id))
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
config = result.scalar_one_or_none()
|
config = result.scalar_one_or_none()
|
||||||
if config is None:
|
if config is None:
|
||||||
config = UserConfig(user_id=user.id, config={})
|
config = UserConfig(user_id=user_id, config={})
|
||||||
session.add(config)
|
session.add(config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
@@ -41,7 +45,7 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
|
|||||||
description="Get the current user's configuration settings.",
|
description="Get the current user's configuration settings.",
|
||||||
)
|
)
|
||||||
async def get_user_config(
|
async def get_user_config(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
"""Get the current user's configuration.
|
"""Get the current user's configuration.
|
||||||
@@ -53,7 +57,8 @@ async def get_user_config(
|
|||||||
Returns:
|
Returns:
|
||||||
The user's configuration settings.
|
The user's configuration settings.
|
||||||
"""
|
"""
|
||||||
config = await _get_or_create_config(session, user.id)
|
_user = await _get_user(session, user_id)
|
||||||
|
config = await _get_or_create_config(session, user_id)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +70,7 @@ async def get_user_config(
|
|||||||
)
|
)
|
||||||
async def update_user_config(
|
async def update_user_config(
|
||||||
data: UserConfigUpdate,
|
data: UserConfigUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
"""Update the current user's configuration.
|
"""Update the current user's configuration.
|
||||||
@@ -78,15 +83,16 @@ async def update_user_config(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated user configuration.
|
The updated user configuration.
|
||||||
"""
|
"""
|
||||||
config = await _get_or_create_config(session, user.id)
|
_user = await _get_user(session, user_id)
|
||||||
|
config = await _get_or_create_config(session, user_id)
|
||||||
|
|
||||||
# Merge updates
|
# Merge updates
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
logger.info("Updating user config for user %s: %s", user.id, update_data)
|
logger.debug("Updating user config for user %s: %s", user_id, update_data)
|
||||||
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||||
config.config = {**config.config, **update_data}
|
config.config = {**config.config, **update_data}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
logger.info("Updated config: %s", config.config)
|
logger.debug("Updated config: %s", config.config)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
"""Config folder API endpoints."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from pydantic import Field
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
|
||||||
from src.models.config_folder import ConfigFolder
|
|
||||||
from src.schemas.config_folder import (
|
|
||||||
ConfigFolderCreate,
|
|
||||||
ConfigFolderUpdate,
|
|
||||||
ConfigFolderResponse,
|
|
||||||
ProjectOverrideCreate,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
|
|
||||||
async def list_config_folders(
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""List config folders for the current user."""
|
|
||||||
query = select(ConfigFolder).where(ConfigFolder.user_id == user_id)
|
|
||||||
result = await session.execute(query)
|
|
||||||
folders = result.scalars().all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"folders": [
|
|
||||||
{
|
|
||||||
"id": str(f.id),
|
|
||||||
"user_id": str(f.user_id),
|
|
||||||
"name": f.name,
|
|
||||||
"description": f.description,
|
|
||||||
"mount_path": f.mount_path,
|
|
||||||
"files": f.files,
|
|
||||||
"project_overrides": f.project_overrides,
|
|
||||||
"is_active": f.is_active,
|
|
||||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
|
||||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
|
||||||
}
|
|
||||||
for f in folders
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_config_folder(
|
|
||||||
data: ConfigFolderCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Create a config folder."""
|
|
||||||
# Check for duplicate name
|
|
||||||
existing = await session.scalar(
|
|
||||||
select(ConfigFolder).where(
|
|
||||||
ConfigFolder.user_id == user_id,
|
|
||||||
ConfigFolder.name == data.name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"config folder with name '{data.name}' already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
folder = ConfigFolder(
|
|
||||||
user_id=user_id,
|
|
||||||
name=data.name,
|
|
||||||
description=data.description,
|
|
||||||
mount_path=data.mount_path,
|
|
||||||
files=data.files,
|
|
||||||
)
|
|
||||||
session.add(folder)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"user_id": str(folder.user_id),
|
|
||||||
"name": folder.name,
|
|
||||||
"description": folder.description,
|
|
||||||
"mount_path": folder.mount_path,
|
|
||||||
"files": folder.files,
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
"is_active": folder.is_active,
|
|
||||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
|
||||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{folder_id}", summary="Update config folder", description="Update an existing config folder.")
|
|
||||||
async def update_config_folder(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
data: ConfigFolderUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Update a config folder."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
if data.name is not None:
|
|
||||||
folder.name = data.name
|
|
||||||
if data.description is not None:
|
|
||||||
folder.description = data.description
|
|
||||||
if data.mount_path is not None:
|
|
||||||
folder.mount_path = data.mount_path
|
|
||||||
if data.files is not None:
|
|
||||||
folder.files = data.files
|
|
||||||
if data.is_active is not None:
|
|
||||||
folder.is_active = data.is_active
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"user_id": str(folder.user_id),
|
|
||||||
"name": folder.name,
|
|
||||||
"description": folder.description,
|
|
||||||
"mount_path": folder.mount_path,
|
|
||||||
"files": folder.files,
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
"is_active": folder.is_active,
|
|
||||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
|
||||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_config_folder(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
"""Delete a config folder."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
await session.delete(folder)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectOverrideWithId(ProjectOverrideCreate):
|
|
||||||
project_id: uuid.UUID = Field(description="Project ID for the override")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.")
|
|
||||||
async def get_config_folder(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Get a config folder by ID."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"user_id": str(folder.user_id),
|
|
||||||
"name": folder.name,
|
|
||||||
"description": folder.description,
|
|
||||||
"mount_path": folder.mount_path,
|
|
||||||
"files": folder.files,
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
"is_active": folder.is_active,
|
|
||||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
|
||||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.")
|
|
||||||
async def add_project_override(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
data: ProjectOverrideWithId,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Add a project override to a config folder."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
# Initialize project_overrides if None
|
|
||||||
if folder.project_overrides is None:
|
|
||||||
folder.project_overrides = {}
|
|
||||||
|
|
||||||
# Add/update override
|
|
||||||
override_data = {}
|
|
||||||
if data.mount_path is not None:
|
|
||||||
override_data["mount_path"] = data.mount_path
|
|
||||||
if data.files is not None:
|
|
||||||
override_data["files"] = data.files
|
|
||||||
|
|
||||||
# Use a copy to trigger SQLAlchemy change detection on JSONB
|
|
||||||
current_overrides = dict(folder.project_overrides or {})
|
|
||||||
current_overrides[str(data.project_id)] = override_data
|
|
||||||
folder.project_overrides = current_overrides
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{folder_id}/overrides/{project_id}", summary="Update project override", description="Update a project override.")
|
|
||||||
async def update_project_override(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
data: ProjectOverrideCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Update a project override."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
# Initialize project_overrides if None
|
|
||||||
if folder.project_overrides is None:
|
|
||||||
folder.project_overrides = {}
|
|
||||||
|
|
||||||
# Update override
|
|
||||||
current_overrides = dict(folder.project_overrides or {})
|
|
||||||
override_data = current_overrides.get(str(project_id), {})
|
|
||||||
if data.mount_path is not None:
|
|
||||||
override_data["mount_path"] = data.mount_path
|
|
||||||
if data.files is not None:
|
|
||||||
override_data["files"] = data.files
|
|
||||||
|
|
||||||
current_overrides[str(project_id)] = override_data
|
|
||||||
folder.project_overrides = current_overrides
|
|
||||||
|
|
||||||
# Mark the field as modified to ensure SQLAlchemy detects the change
|
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
|
||||||
flag_modified(folder, "project_overrides")
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{folder_id}/overrides/{project_id}", summary="Remove project override", description="Remove a project override.")
|
|
||||||
async def remove_project_override(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
"""Remove a project override."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
# Remove override if exists
|
|
||||||
current_overrides = dict(folder.project_overrides or {})
|
|
||||||
if str(project_id) in current_overrides:
|
|
||||||
del current_overrides[str(project_id)]
|
|
||||||
folder.project_overrides = current_overrides
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"project_overrides": folder.project_overrides or {},
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
"""Config profile API endpoints."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
|
||||||
from src.models.config_include import ConfigInclude
|
|
||||||
from src.models.config_mount import ConfigMount
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.models.user_config import UserConfig
|
|
||||||
from src.schemas.config_profile import (
|
|
||||||
ConfigIncludeCreate,
|
|
||||||
ConfigIncludeUpdate,
|
|
||||||
ConfigMountCreate,
|
|
||||||
ConfigMountUpdate,
|
|
||||||
ConfigProfileCreate,
|
|
||||||
ConfigProfileUpdate,
|
|
||||||
DefaultProfilesUpdate,
|
|
||||||
)
|
|
||||||
from src.services.config_profiles import (
|
|
||||||
check_duplicate_include,
|
|
||||||
check_duplicate_mount_path,
|
|
||||||
check_duplicate_name,
|
|
||||||
get_default_profile_for_tool_type,
|
|
||||||
get_default_profiles,
|
|
||||||
get_owned_profile,
|
|
||||||
include_to_dict,
|
|
||||||
list_includes_for_profile,
|
|
||||||
list_mounts_for_profile,
|
|
||||||
mount_to_dict,
|
|
||||||
profile_to_dict,
|
|
||||||
set_default_profiles,
|
|
||||||
validate_includes_no_cycle,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_config_profiles(
|
|
||||||
tool_type_id: str | None = None,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
|
|
||||||
if tool_type_id:
|
|
||||||
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
|
||||||
if tool_type is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
|
||||||
result = await session.execute(query.order_by(ConfigProfile.name))
|
|
||||||
return {"profiles": [profile_to_dict(p) for p in result.scalars().all()]}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_config_profile(
|
|
||||||
data: ConfigProfileCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
await check_duplicate_name(session, user_id, data.name)
|
|
||||||
profile = ConfigProfile(user_id=user_id, name=data.name, description=data.description)
|
|
||||||
session.add(profile)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(profile)
|
|
||||||
return profile_to_dict(profile)
|
|
||||||
|
|
||||||
|
|
||||||
@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:
|
|
||||||
return await get_default_profiles(session, user_id)
|
|
||||||
|
|
||||||
|
|
||||||
@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:
|
|
||||||
return await set_default_profiles(session, user_id, data.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:
|
|
||||||
return await get_default_profile_for_tool_type(session, user_id, tool_type_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}")
|
|
||||||
async def get_config_profile(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
profile = await session.get(
|
|
||||||
ConfigProfile,
|
|
||||||
profile_id,
|
|
||||||
options=[selectinload(ConfigProfile.includes), selectinload(ConfigProfile.mounts)],
|
|
||||||
)
|
|
||||||
if profile is None or profile.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found")
|
|
||||||
includes_data = []
|
|
||||||
for inc in profile.includes:
|
|
||||||
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
|
||||||
includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
|
|
||||||
return {
|
|
||||||
**profile_to_dict(profile),
|
|
||||||
"includes": includes_data,
|
|
||||||
"mounts": [mount_to_dict(m) for m in profile.mounts],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}")
|
|
||||||
async def update_config_profile(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
data: ConfigProfileUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
profile = await get_owned_profile(profile_id, user_id, session)
|
|
||||||
if data.name is not None:
|
|
||||||
await check_duplicate_name(session, user_id, data.name, exclude_id=profile_id)
|
|
||||||
profile.name = data.name
|
|
||||||
if data.description is not None:
|
|
||||||
profile.description = data.description
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(profile)
|
|
||||||
return profile_to_dict(profile)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_config_profile(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
profile = await get_owned_profile(profile_id, user_id, session)
|
|
||||||
await session.delete(profile)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}/includes")
|
|
||||||
async def list_profile_includes(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
return await list_includes_for_profile(session, profile_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{profile_id}/includes", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def add_profile_include(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
data: ConfigIncludeCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
profile = await get_owned_profile(profile_id, user_id, session)
|
|
||||||
included_profile_id = uuid.UUID(data.included_profile_id)
|
|
||||||
if included_profile_id == profile_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="a profile cannot include itself")
|
|
||||||
included_profile = await session.get(ConfigProfile, included_profile_id)
|
|
||||||
if included_profile is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="included profile not found")
|
|
||||||
if included_profile.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="included profile does not belong to user")
|
|
||||||
await check_duplicate_include(session, profile_id, included_profile_id)
|
|
||||||
await validate_includes_no_cycle(session, profile_id, included_profile_id)
|
|
||||||
include = ConfigInclude(
|
|
||||||
profile_id=profile_id,
|
|
||||||
included_profile_id=included_profile_id,
|
|
||||||
order_index=data.order_index,
|
|
||||||
)
|
|
||||||
session.add(include)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(include)
|
|
||||||
return include_to_dict(include, included_profile.name)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}/includes/{include_id}")
|
|
||||||
async def update_profile_include(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
include_id: uuid.UUID,
|
|
||||||
data: ConfigIncludeUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
include = await session.get(ConfigInclude, include_id)
|
|
||||||
if include is None or include.profile_id != profile_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
|
|
||||||
include.order_index = data.order_index
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(include)
|
|
||||||
included_profile = await session.get(ConfigProfile, include.included_profile_id)
|
|
||||||
return include_to_dict(include, included_profile.name if included_profile else None)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{profile_id}/includes/{include_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def remove_profile_include(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
include_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
include = await session.get(ConfigInclude, include_id)
|
|
||||||
if include is None or include.profile_id != profile_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
|
|
||||||
await session.delete(include)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}/mounts")
|
|
||||||
async def list_profile_mounts(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
return await list_mounts_for_profile(session, profile_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{profile_id}/mounts", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def add_profile_mount(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
data: ConfigMountCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
profile = await get_owned_profile(profile_id, user_id, session)
|
|
||||||
await check_duplicate_mount_path(session, profile_id, data.target_path)
|
|
||||||
mount = ConfigMount(
|
|
||||||
profile_id=profile_id,
|
|
||||||
target_path=data.target_path,
|
|
||||||
mode=data.mode,
|
|
||||||
files=data.files,
|
|
||||||
order_index=data.order_index,
|
|
||||||
)
|
|
||||||
session.add(mount)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(mount)
|
|
||||||
return mount_to_dict(mount)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}/mounts/{mount_id}")
|
|
||||||
async def update_profile_mount(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
mount_id: uuid.UUID,
|
|
||||||
data: ConfigMountUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
mount = await session.get(ConfigMount, mount_id)
|
|
||||||
if mount is None or mount.profile_id != profile_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
|
|
||||||
if data.target_path is not None:
|
|
||||||
await check_duplicate_mount_path(session, profile_id, data.target_path, exclude_id=mount_id)
|
|
||||||
mount.target_path = data.target_path
|
|
||||||
if data.files is not None:
|
|
||||||
mount.files = data.files
|
|
||||||
if data.order_index is not None:
|
|
||||||
mount.order_index = data.order_index
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(mount)
|
|
||||||
return mount_to_dict(mount)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{profile_id}/mounts/{mount_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def remove_profile_mount(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
mount_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
mount = await session.get(ConfigMount, mount_id)
|
|
||||||
if mount is None or mount.profile_id != profile_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
|
|
||||||
await session.delete(mount)
|
|
||||||
await session.commit()
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
"""Git repository API endpoints."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
|
|
||||||
from src.models.project import Project
|
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.git_repository import (
|
|
||||||
BranchCreateRequest,
|
|
||||||
CheckoutRequest,
|
|
||||||
CommitRequest,
|
|
||||||
CommitResponse,
|
|
||||||
FetchResponse,
|
|
||||||
FileContentResponse,
|
|
||||||
FileListResponse,
|
|
||||||
FileUpdateRequest,
|
|
||||||
FileUpdateResponse,
|
|
||||||
GitRepositoryCreate,
|
|
||||||
GitRepositoryResponse,
|
|
||||||
MergeRequest,
|
|
||||||
MergeResponse,
|
|
||||||
PullResponse,
|
|
||||||
PushResponse,
|
|
||||||
StatusResponse,
|
|
||||||
URLParseRequest,
|
|
||||||
URLParseResponse,
|
|
||||||
)
|
|
||||||
from src.services.git import control as git_control
|
|
||||||
from src.services.git import files as git_files
|
|
||||||
from src.services.git.repository import create_repository, delete_repository, list_repositories
|
|
||||||
from src.utils.git_url_parser import parse_git_url
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse])
|
|
||||||
async def list_repositories_endpoint(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
):
|
|
||||||
return await list_repositories(session, project_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_repository_endpoint(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
data: GitRepositoryCreate,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
):
|
|
||||||
return await create_repository(session, project_id, data, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_repository_endpoint(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
):
|
|
||||||
await delete_repository(session, repo_id, project_id)
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/repositories/parse-url", response_model=URLParseResponse)
|
|
||||||
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
|
||||||
return URLParseResponse(**parse_git_url(data.url))
|
|
||||||
|
|
||||||
|
|
||||||
# History
|
|
||||||
|
|
||||||
@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: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
from src.utils.git_history import get_commit_history
|
|
||||||
from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
|
|
||||||
except RuntimeError as e:
|
|
||||||
logger.warning("Git history failed for %s: %s", repo.path, str(e))
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Git history unavailable: {str(e)}",
|
|
||||||
) from 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: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
from src.utils.git_history import get_commit_detail
|
|
||||||
from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
return get_commit_detail(repo.path, commit_hash)
|
|
||||||
except RuntimeError as e:
|
|
||||||
logger.warning("Git commit detail failed for %s %s: %s", repo.path, commit_hash, str(e))
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Commit detail unavailable: {str(e)}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
# File browsing
|
|
||||||
|
|
||||||
@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: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> FileListResponse:
|
|
||||||
return await git_files.list_files(session, project_id, repo_id, branch, path)
|
|
||||||
|
|
||||||
|
|
||||||
@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: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> FileContentResponse:
|
|
||||||
return await git_files.get_file(session, project_id, repo_id, branch, path)
|
|
||||||
|
|
||||||
|
|
||||||
@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: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> FileUpdateResponse:
|
|
||||||
return await git_files.update_file(session, project_id, repo_id, data, user)
|
|
||||||
|
|
||||||
|
|
||||||
# Branches
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/branches")
|
|
||||||
async def get_repository_branches(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
return await git_files.list_branches_with_validation(session, project_id, repo_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/branches")
|
|
||||||
async def create_repository_branch(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: BranchCreateRequest,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
return await git_control.create_branch_with_validation(session, project_id, repo_id, data)
|
|
||||||
|
|
||||||
|
|
||||||
@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: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
return await git_control.delete_branch_with_validation(session, project_id, repo_id, branch_name, force)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/checkout")
|
|
||||||
async def checkout_repository_branch(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: CheckoutRequest,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
return await git_control.checkout_branch_with_validation(session, project_id, repo_id, data)
|
|
||||||
|
|
||||||
|
|
||||||
# Git control
|
|
||||||
|
|
||||||
@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: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> StatusResponse:
|
|
||||||
return await git_control.get_status_with_validation(session, project_id, repo_id)
|
|
||||||
|
|
||||||
|
|
||||||
@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: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> CommitResponse:
|
|
||||||
result = await git_control.commit_changes_with_validation(session, project_id, repo_id, data, user)
|
|
||||||
return CommitResponse(commit_hash=result["commit_hash"], message=result["message"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse)
|
|
||||||
async def fetch_repository(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> FetchResponse:
|
|
||||||
return await git_control.fetch_with_validation(session, project_id, repo_id)
|
|
||||||
|
|
||||||
|
|
||||||
@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: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> PullResponse:
|
|
||||||
return await git_control.pull_with_validation(session, project_id, repo_id, branch)
|
|
||||||
|
|
||||||
|
|
||||||
@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: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> PushResponse:
|
|
||||||
return await git_control.push_with_validation(session, project_id, repo_id, branch)
|
|
||||||
|
|
||||||
|
|
||||||
@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: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> MergeResponse:
|
|
||||||
return await git_control.merge_with_validation(session, project_id, repo_id, data)
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# apps/api/src/api/project (index)
|
||||||
|
dir: apps/api/src/api/project
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides REST API endpoints for project and Git repository management in the backend service.
|
||||||
|
## 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,25 +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 sqlalchemy import select
|
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, get_db_session, get_owned_project
|
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.user import User
|
from src.models import ToolInstance
|
||||||
from src.schemas.project import (
|
from src.schemas.project import (
|
||||||
ProjectCreate,
|
ProjectCreate,
|
||||||
ProjectUpdate,
|
|
||||||
ProjectResponse,
|
ProjectResponse,
|
||||||
|
ProjectUpdate,
|
||||||
SetDefaultSSHKeyRequest,
|
SetDefaultSSHKeyRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=ProjectResponse,
|
response_model=ProjectResponse,
|
||||||
@@ -31,7 +35,7 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
|||||||
)
|
)
|
||||||
async def create_project(
|
async def create_project(
|
||||||
data: ProjectCreate,
|
data: ProjectCreate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Create a new project.
|
"""Create a new project.
|
||||||
@@ -44,6 +48,7 @@ async def create_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The newly created project.
|
The newly created project.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
project = Project(
|
project = Project(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
@@ -58,25 +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: User = Depends(get_current_user),
|
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.
|
|
||||||
"""
|
"""
|
||||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
user = await _get_user(session, user_id)
|
||||||
return list(result.scalars().all())
|
result = await session.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(Project.owner_id == user.id)
|
||||||
|
.order_by(Project.created_at.desc())
|
||||||
|
)
|
||||||
|
projects = result.scalars().all()
|
||||||
|
|
||||||
|
from src.models import Workspace
|
||||||
|
|
||||||
|
enriched = []
|
||||||
|
for project in projects:
|
||||||
|
repos_result = await session.execute(
|
||||||
|
select(GitRepository).where(GitRepository.project_id == project.id)
|
||||||
|
)
|
||||||
|
repositories = []
|
||||||
|
for repo in repos_result.scalars().all():
|
||||||
|
ws_result = await session.execute(
|
||||||
|
select(Workspace).where(Workspace.repo_id == repo.id)
|
||||||
|
)
|
||||||
|
workspaces = []
|
||||||
|
for ws in ws_result.scalars().all():
|
||||||
|
# Count instances
|
||||||
|
inst_result = await session.execute(
|
||||||
|
select(func.count()).where(ToolInstance.workspace_id == ws.id)
|
||||||
|
)
|
||||||
|
instance_count = inst_result.scalar() or 0
|
||||||
|
workspaces.append(
|
||||||
|
{
|
||||||
|
"id": str(ws.id),
|
||||||
|
"name": ws.name,
|
||||||
|
"branch": ws.branch,
|
||||||
|
"status": ws.status,
|
||||||
|
"instance_count": instance_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
repositories.append(
|
||||||
|
{
|
||||||
|
"id": str(repo.id),
|
||||||
|
"name": repo.name,
|
||||||
|
"remote_url": repo.remote_url,
|
||||||
|
"workspaces": workspaces,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
enriched.append(
|
||||||
|
{
|
||||||
|
"id": str(project.id),
|
||||||
|
"name": project.name,
|
||||||
|
"description": project.description,
|
||||||
|
"owner_id": str(project.owner_id),
|
||||||
|
"repositories": repositories,
|
||||||
|
"created_at": project.created_at.isoformat()
|
||||||
|
if project.created_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -87,8 +144,7 @@ async def list_projects(
|
|||||||
)
|
)
|
||||||
async def get_project(
|
async def get_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Get a specific project by ID.
|
"""Get a specific project by ID.
|
||||||
@@ -101,8 +157,8 @@ async def get_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The requested project.
|
The requested project.
|
||||||
"""
|
"""
|
||||||
return project
|
await _get_user(session, user_id)
|
||||||
|
return await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
@@ -114,8 +170,7 @@ async def get_project(
|
|||||||
async def update_project(
|
async def update_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: ProjectUpdate,
|
data: ProjectUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Update a project.
|
"""Update a project.
|
||||||
@@ -129,6 +184,8 @@ async def update_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated project.
|
The updated project.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
project.name = data.name
|
project.name = data.name
|
||||||
@@ -148,8 +205,7 @@ async def update_project(
|
|||||||
)
|
)
|
||||||
async def delete_project(
|
async def delete_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Delete a project and all its repositories.
|
"""Delete a project and all its repositories.
|
||||||
@@ -162,9 +218,13 @@ async def delete_project(
|
|||||||
Returns:
|
Returns:
|
||||||
Empty response with 204 status code.
|
Empty response with 204 status code.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
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):
|
||||||
@@ -185,8 +245,7 @@ async def delete_project(
|
|||||||
async def set_default_ssh_key(
|
async def set_default_ssh_key(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: SetDefaultSSHKeyRequest,
|
data: SetDefaultSSHKeyRequest,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Set the default SSH key for a project.
|
"""Set the default SSH key for a project.
|
||||||
@@ -200,6 +259,8 @@ async def set_default_ssh_key(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated project.
|
The updated project.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
ssh_key = await session.get(SSHKey, data.ssh_key_id)
|
ssh_key = await session.get(SSHKey, data.ssh_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:
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Shared Pydantic validators for API schemas."""
|
||||||
|
|
||||||
|
|
||||||
|
MAX_FOLDER_SIZE_MB = 10
|
||||||
|
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mount_path(v: str | None) -> str | None:
|
||||||
|
"""Validate that a mount path is absolute (starts with /).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: Mount path string or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated path, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path is not absolute.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Mount path must be absolute (start with /)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def validate_files(v: dict | None, max_size_bytes: int = MAX_FOLDER_SIZE_BYTES) -> dict | None:
|
||||||
|
"""Validate file dict for path traversal and size limits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: Dict of {path: content} or None.
|
||||||
|
max_size_bytes: Maximum total size in bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated dict, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path traversal detected or size limit exceeded.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
for path, content in v.items():
|
||||||
|
# Check for path traversal
|
||||||
|
if ".." in path or path.startswith("/"):
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
total_size += len(content.encode("utf-8"))
|
||||||
|
|
||||||
|
if total_size > max_size_bytes:
|
||||||
|
raise ValueError(f"Total folder size exceeds {max_size_bytes // (1024 * 1024)}MB limit")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def validate_env_vars(v: dict | None) -> dict | None:
|
||||||
|
"""Validate that environment variables is a JSON object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: Dict of env vars or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated dict, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If not a dict.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError("environment_variables must be a JSON object")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def validate_volumes(v: list | None) -> list | None:
|
||||||
|
"""Validate volume mounts list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: List of volume dicts or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated list, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If not a list or missing required fields.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, list):
|
||||||
|
raise ValueError("volumes must be a JSON array")
|
||||||
|
for i, vol in enumerate(v):
|
||||||
|
if not isinstance(vol, dict):
|
||||||
|
raise ValueError(f"Volume at index {i} must be an object")
|
||||||
|
if "source" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||||
|
if "target" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||||
|
return v
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# apps/api/src/api/system (index)
|
||||||
|
dir: apps/api/src/api/system
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides system-level API endpoints for monitoring, administration, and infrastructure interaction including dashboards, health checks, events, notifications, and container terminal access.
|
||||||
|
## parent
|
||||||
|
index: apps/api/src/api/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- dashboard.py
|
||||||
|
- events.py
|
||||||
|
- health.py
|
||||||
|
- instance_proxy.py
|
||||||
|
- notifications.py
|
||||||
|
- terminal.py
|
||||||
|
## links
|
||||||
|
index: apps/api/src/api/system/.pi-map.index.md
|
||||||
|
map: apps/api/src/api/system/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change system behavior
|
||||||
|
read: __init__.py, dashboard.py, events.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# apps/api/src/api/system
|
||||||
|
dir: apps/api/src/api/system
|
||||||
|
|
||||||
|
index: apps/api/src/api/system/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides system-level API endpoints for monitoring, administration, and infrastructure interaction including dashboards, health checks, events, notifications, and container terminal access.
|
||||||
|
## files
|
||||||
|
- __init__.py | Aggregates and re-exports system API router modules from submodules for centralized access. | dep: src.api.system.dashboard, src.api.system.events, src.api.system.health, src.api.system.instance_proxy, src.api.system.notifications, src.api.system.terminal
|
||||||
|
- dashboard.py | Provides a FastAPI endpoint that returns a dashboard summary with aggregated counts of projects, repositories, SSH keys, and recent activity for the authenticated user. | exp: func:get_dashboard_summary(user_id, session) → dict, call:session.execute, call:select(func.count()).select_from(Project).where, call:func.count, call:projects_result.scalar, call:select(func.count()).select_from(GitRepository).where, call:repos_result.scalar, call:select(func.count()).select_from(SSHKey).where, call:ssh_keys_result.scalar, call:select(Project) .where(Project.owner_id == user_id) .order_by(Project.created_at.desc()) .limit, call:Project.created_at.desc, call:recent_projects.scalars().all | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.models.project, src.models (GitRepository, Project, SSHKey)
|
||||||
|
- events.py | Implements an SSE streaming endpoint that delivers instance events to authenticated users with per-user connection limits and keep-alive pings. | exp: func:events_stream(request: Request, user_id) → StreamingResponse, call:_connection_counts.get, call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.pop, call:StreamingResponse, call:event_generator, raise:HTTPException, func:event_generator() → AsyncGenerator[str, None], call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.get, call:_connection_counts.pop, func:on_event(payload: InstanceEventPayload) → None, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait | dep: asyncio, contextlib, json, uuid, collections.abc, fastapi, fastapi.responses, src.auth.dependencies, src.services.instance.event_bus
|
||||||
|
- health.py | Implements FastAPI health check endpoints for monitoring system status including database connectivity and disk space. | exp: func:health_check() → dict[str, Any], call:HealthChecks, call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealth, call:round, call:shutil.disk_usage, call:DiskHealth, call:HealthResponse( status=overall_status, timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), version="0.1.0", checks=checks, uptime_seconds=round(time.time() - _start_time, 2), ).model_dump, call:datetime.now(timezone.utc).isoformat().replace, call:time.time, func:health_check_db() → dict[str, Any], call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealthResponse( status="healthy", response_time_ms=round(db_time, 2), ).model_dump, call:round, call:DatabaseHealthResponse( status="unhealthy", response_time_ms=0.0, ).model_dump | dep: time, datetime, typing, fastapi, sqlalchemy, src.database, src.schemas.system, shutil
|
||||||
|
- instance_proxy.py | Proxies HTTP requests from FastAPI endpoints to running containerized tool instances after verifying ownership and status. | exp: func:_proxy_request(request: Request, instance_id: uuid.UUID, path: str, user_id: uuid.UUID, session: AsyncSession) → Response, call:session.get, call:str, call:request.headers.items, call:key.lower, call:httpx.AsyncClient, call:request.body, call:client.request, call:logger.error, call:dict, call:response_headers.pop, call:Response, raise:HTTPException, func:proxy_to_instance(request: Request, instance_id: uuid.UUID, path, user_id, session) → Response, call:_proxy_request | dep: logging, uuid, httpx, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models
|
||||||
|
- notifications.py | Defines FastAPI REST endpoints for managing user notifications (list, unread count, mark read, dismiss/clear) with support for muted categories from user config. | exp: class:NotificationItem, class:NotificationListResponse, class:UnreadCountResponse, class:MarkAllReadResponse, class:ClearAllResponse, func:_get_mute_categories(session: AsyncSession, user_id: uuid.UUID) → list[str], call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:config.config.get, call:isinstance, func:list_notifications(limit, offset, unread_only, user, session) → NotificationListResponse, call:_get_mute_categories, call:notification_service.list_notifications, call:NotificationListResponse, call:NotificationItem.model_validate, func:get_unread_count(user, session) → UnreadCountResponse, call:notification_service.get_unread_count, call:UnreadCountResponse, func:mark_notification_read(notification_id: uuid.UUID, user, session) → NotificationItem, call:notification_service.mark_read, call:NotificationItem.model_validate, raise:HTTPException, func:mark_all_read(user, session) → MarkAllReadResponse, call:notification_service.mark_all_read, call:MarkAllReadResponse, func:clear_all_notifications(user, session) → ClearAllResponse, call:notification_service.dismiss_all, call:ClearAllResponse, func:dismiss_notification(notification_id: uuid.UUID, user, session) → None, call:notification_service.dismiss, raise:HTTPException | dep: uuid, datetime, fastapi, pydantic, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models.user, src.models, src.services.shared.notification_service, sqlalchemy
|
||||||
|
- terminal.py | Provides WebSocket and HTTP endpoints for managing interactive terminal sessions attached to running Docker container tool instances, including session creation, attachment, input/output streaming, resize/reset control messages, and session listing. | exp: class:SessionRef, method:__init__(self, session, slot_session_id), func:terminal_websocket_default(websocket: WebSocket, instance_id: str, db_session) → None, call:_handle_terminal_websocket, func:terminal_websocket_specific(websocket: WebSocket, instance_id: str, session_id: str, db_session) → None, call:_handle_terminal_websocket, func:_handle_terminal_websocket(websocket: WebSocket, instance_id: str, target_session_id: str | None, db_session: AsyncSession) → None, call:logger.debug, call:websocket.accept, call:uuid.UUID, call:logger.error, call:websocket.close, call:_get_user_from_websocket, call:logger.warning, call:db_session.get, call:get_container_status, call:terminal_manager.get_or_create_session, call:terminal_manager.get_session, call:logger.info, call:terminal_manager.create_session, call:terminal_manager._find_key_by_internal_id, call:terminal_manager.attach_websocket, call:websocket.send_json, call:SessionRef, call:asyncio.create_task, call:_write_loop, call:_heartbeat_loop, call:asyncio.wait, call:len, call:task.cancel, call:str, call:suppress, call:terminal_manager.detach_websocket, func:_write_loop(session_ref: SessionRef, websocket, instance_id: str) → None, call:session.is_alive, call:asyncio.sleep, call:websocket.receive, call:session.write_input, call:text.startswith, call:json.loads, call:ctrl.get, call:logger.debug, call:session.resize, call:session.acknowledge_data, call:websocket.send_json, call:terminal_manager.reset_session, call:terminal_manager.attach_websocket, call:text.encode, func:_heartbeat_loop(websocket: WebSocket) → None, call:asyncio.sleep, call:websocket.send_json, func:_get_terminal_instance(instance_id: uuid.UUID, user_id: uuid.UUID, db_session: AsyncSession) → ToolInstance, call:db_session.get, raise:HTTPException, func:list_terminal_sessions(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.execute, call:select(TerminalSessionModel) .where(TerminalSessionModel.instance_id == instance_id) .where(TerminalSessionModel.status != "closed") .order_by, call:TerminalSessionModel.created_at.asc, call:result.scalars().all, call:terminal_manager.get_session, call:str, call:sessions.append, call:live_session.has_websockets, call:row.created_at.isoformat, call:row.last_activity_at.isoformat, func:create_terminal_session(instance_id: uuid.UUID, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:data.get, call:terminal_manager.create_session, raise:HTTPException, func:close_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:terminal_manager.close_session, raise:HTTPException, func:reset_specific_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:db_session.get, call:terminal_manager.reset_session, raise:HTTPException, func:rename_terminal_session(instance_id: uuid.UUID, session_id: str, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:data.get, call:isinstance, call:terminal_manager.get_session, call:str, call:db_session.get, call:uuid.UUID, call:db_session.commit, raise:HTTPException, func:reset_terminal_session(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:terminal_manager.reset_session, call:logger.info, call:str, call:logger.error, raise:HTTPException, func:_get_user_from_websocket(websocket: WebSocket, db_session: AsyncSession) → uuid.UUID | None, call:websocket.cookies.get, call:Settings, call:decode_session_cookie, call:uuid.UUID, call:str | dep: asyncio, json, logging, uuid, contextlib, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, starlette.websockets, src.auth.dependencies, src.models, src.services.terminal.terminal_manager, src.services.docker, src.auth.session, src.config, starlette
|
||||||
|
## arch
|
||||||
|
FastAPI modular router pattern with separate files per domain concern, combining REST endpoints, SSE streaming, WebSocket connections, and HTTP proxying; uses dependency injection for auth and integrates with Docker/container runtime for terminal and instance proxy features.
|
||||||
|
## tags
|
||||||
|
session, terminal, call:terminal, call:, src, get, response, websocket
|
||||||
|
## symbols
|
||||||
|
- NotificationItem
|
||||||
|
- NotificationListResponse
|
||||||
|
- UnreadCountResponse
|
||||||
|
- MarkAllReadResponse
|
||||||
|
- ClearAllResponse
|
||||||
|
- SessionRef
|
||||||
|
- get_dashboard_summary
|
||||||
|
- events_stream
|
||||||
|
## workflows
|
||||||
|
- change system behavior
|
||||||
|
read: __init__.py, dashboard.py, events.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user