Compare commits
542 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 99097090e6 | |||
| 2a9e57ad0d | |||
| e4c5e7f2db | |||
| 70957e462a | |||
| 6f35eb77ae | |||
| 684a11610a | |||
| 575e5837ea | |||
| 996ea73bbf | |||
| d0e5feeaa5 | |||
| 1f784b552d | |||
| 10cbe63095 | |||
| 4547105f3b | |||
| dacf105200 | |||
| 2525c58471 | |||
| 8dd350286e | |||
| 5af4de0d7e | |||
| ebfe991a15 | |||
| cc1507a33e | |||
| ae377baa74 | |||
| a0d4e76662 | |||
| b61c2256a8 | |||
| 7e6588a99d | |||
| 56b54e269a | |||
| 6e597b9e21 | |||
| a2c3d6877e | |||
| a200955ef2 | |||
| 76266fc3d0 | |||
| 9d54a2542b | |||
| 520078fd64 | |||
| 55c4dc1281 | |||
| 74ac7b00bb | |||
| ecc0acc8dd | |||
| fba01ddfb2 | |||
| 35a251a0b4 | |||
| b10eadf64b | |||
| 50fcf5c077 | |||
| 19db7db8d0 | |||
| 189f29ee41 | |||
| f2c3264be6 | |||
| f07a632c86 | |||
| 0b68efb6e0 | |||
| 54ec87a836 | |||
| 3d64ec9061 | |||
| ac6bd3304d | |||
| 6ec35988cc | |||
| e985f0122e | |||
| 2bd778117f | |||
| 23ae12e69c | |||
| 65d4fad3c5 | |||
| e6f64c39f3 | |||
| e7c42c17b9 | |||
| f7be50952a | |||
| 402e662c0c | |||
| 7450dd0ce5 | |||
| 7a88639250 | |||
| 1d33735e2f | |||
| d11b43b69f | |||
| 294d02f9fb | |||
| a10029f36c | |||
| 6a2ebae8c9 | |||
| 1a922d4171 | |||
| d906c12aa9 | |||
| 4906ce0cd9 | |||
| e7804c0f58 | |||
| b40eb3e88c | |||
| 98795e31dd | |||
| b4a7627718 | |||
| 9d27cd9fe8 | |||
| e3ef852eca | |||
| f824a1e6fe | |||
| ad09ffa6ec | |||
| 63ae706dd0 | |||
| 74b5d0dc8c | |||
| 25662e525c | |||
| d4e992a9e2 | |||
| 35ada0e662 | |||
| c3e2264771 | |||
| 76741d3ee6 | |||
| 62752a8390 | |||
| 461cb30b28 | |||
| 94aa88c154 | |||
| 4f695d7e62 | |||
| 7a48180dc3 | |||
| a0b0944709 | |||
| b79da51269 | |||
| 40a940304b | |||
| e344e961d6 | |||
| d6b3e8b804 | |||
| f997b3f7c5 | |||
| c795f8f873 | |||
| c52367401b | |||
| e9e0e87013 | |||
| 2b5331a1a0 | |||
| 6f41fa7cbe | |||
| cccc4a9d5a | |||
| 72b4a5e2bd | |||
| 8f1d2a149a | |||
| 7261c75bb2 | |||
| 84bf7e4aeb | |||
| 9373d93169 | |||
| 7fc8b82621 | |||
| 2d078c8b1e | |||
| 92c8dfe986 | |||
| 0bc0d99c21 | |||
| 965f6f6585 | |||
| a9f4657d03 | |||
| 875594d66d | |||
| 762e0de44c |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"fingerprint": "c36b11ec5edebc02aa51b1113a7a11dc2559e812"
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Skill Registry — headquarter
|
||||||
|
|
||||||
|
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
||||||
|
|
||||||
|
Last updated: 2026-06-02
|
||||||
|
|
||||||
|
## Sources scanned
|
||||||
|
|
||||||
|
- .opencode/skills
|
||||||
|
- .claude/skills
|
||||||
|
- /home/alex/.config/opencode/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 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
|
||||||
|
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.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 | `/home/alex/projects/headquarter/.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 | `/home/alex/projects/headquarter/.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 | `/home/alex/projects/headquarter/.opencode/skills/openspec-propose/SKILL.md` |
|
||||||
|
| `sift-backlog` | Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open. | project | `/home/alex/projects/headquarter/.claude/skills/sift-backlog/SKILL.md` |
|
||||||
|
|
||||||
|
## Loading protocol
|
||||||
|
|
||||||
|
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,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
|
||||||
@@ -48,3 +48,9 @@ apps/web/dist/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
/.stoneforge/.worktrees/
|
||||||
|
# Pi / agent cache
|
||||||
|
.pi/
|
||||||
|
.atl/
|
||||||
|
.sisyphus/
|
||||||
|
.pi-lens/
|
||||||
|
|||||||
@@ -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,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,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:
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Project Management** - Create and manage projects with dashboard view
|
||||||
|
- **Git Repository Management** - Bare repository initialization and mirror cloning with smart URL parsing
|
||||||
|
- **Repository Workspace** - File browser with syntax highlighting, branch switching, and file editing
|
||||||
|
- **Git History Visualization** - Commit history with graph visualization and diff viewing
|
||||||
|
- **Git Control** - Branch management, commit, fetch/pull/push, merge operations
|
||||||
|
- **File Editor** - Syntax highlighting for 50+ languages with edit/commit workflow
|
||||||
|
- **Smart Git URL Parsing** - Automatic detection and correction of browser URLs to git clone URLs
|
||||||
|
- **OAuth2 Authentication** - Session-based authentication via Authentik with simplified flow
|
||||||
|
- **User Profile** - Profile management with avatar upload
|
||||||
|
- **User Settings** - Theme selection, git identity, and preference management
|
||||||
|
- **SSH Key Management** - Ed25519 key generation with secure storage
|
||||||
|
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
||||||
|
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Simplified authentication from JWT to session-based cookies
|
||||||
|
- Restructured test infrastructure with unit/integration/system separation
|
||||||
|
- Improved Docker deployment with Traefik integration
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Database migration chain errors
|
||||||
|
- Cross-origin cookie handling for OAuth flow
|
||||||
|
- Nginx permission issues in container
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-05-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial release with core project and repository management
|
||||||
|
- OAuth2 authentication with Authentik
|
||||||
|
- Basic file browsing and git history viewing
|
||||||
|
- Development tool type definitions
|
||||||
@@ -1,53 +1,195 @@
|
|||||||
|
# Headquarter
|
||||||
|
|
||||||
## Testing Strategy
|
A self-hosted platform for managing projects, git repositories, and development tools with OAuth2 authentication.
|
||||||
|
|
||||||
The project uses a three-tier testing approach:
|
## Overview
|
||||||
|
|
||||||
### Test Categories
|
Headquarter provides a centralized workspace for development teams to:
|
||||||
|
- Manage projects and their associated git repositories
|
||||||
|
- Browse repository files and view git history
|
||||||
|
- Spawn development tools (VS Code Server, Jupyter Notebook, etc.)
|
||||||
|
- Manage SSH keys and user preferences
|
||||||
|
|
||||||
1. **Unit Tests** (`apps/api/tests/unit/`)
|
## Features
|
||||||
- Fast tests with no external dependencies
|
|
||||||
- Use SQLite in-memory database
|
|
||||||
- Run with: `make test-unit` or `pytest -m unit`
|
|
||||||
|
|
||||||
2. **Integration Tests** (`apps/api/tests/integration/`)
|
### Project Management
|
||||||
- Test API endpoints with database
|
- Create and manage projects
|
||||||
- Use PostgreSQL with transaction rollback
|
- View all projects in a dashboard
|
||||||
- Run with: `make test-integration` or `pytest -m integration`
|
- Click any project to open its workspace
|
||||||
|
|
||||||
3. **System/E2E Tests** (`e2e/`)
|
### Git Repository Management
|
||||||
- End-to-end tests using Playwright
|
- Initialize bare repositories
|
||||||
- Test full user journeys
|
- Clone repositories (including mirror clones)
|
||||||
- Run with: `make test-e2e`
|
- Smart URL parsing (converts browser URLs to git URLs)
|
||||||
|
- View repository history and commit details
|
||||||
|
|
||||||
### Running Tests
|
### Repository Workspace
|
||||||
|
- Browse files and directories
|
||||||
|
- View file contents with syntax highlighting
|
||||||
|
- Switch between branches
|
||||||
|
- Quick file editing with automatic commits
|
||||||
|
|
||||||
```bash
|
### Git History Visualization
|
||||||
# Run all tests (excludes system tests by default)
|
- View commit history with branch graph
|
||||||
make test
|
- See commit details, statistics, and diffs
|
||||||
|
- Filter by branch
|
||||||
|
|
||||||
# Run specific categories
|
### Authentication
|
||||||
make test-unit # Fast unit tests only
|
- OAuth2 via Authentik
|
||||||
make test-integration # Integration tests with DB
|
- Session-based authentication
|
||||||
make test-system # Full stack tests
|
- User profile management
|
||||||
make test-e2e # Browser-based E2E tests
|
|
||||||
|
|
||||||
# Inside Docker container
|
### Tool Management
|
||||||
docker compose exec api pytest -v -m unit
|
- Built-in tool types (code-server, jupyter-notebook)
|
||||||
docker compose exec api pytest -v -m integration
|
- Create custom tool types with Docker Compose templates
|
||||||
|
- Template validation
|
||||||
|
|
||||||
|
### User Settings
|
||||||
|
- Theme selection (system/light/dark)
|
||||||
|
- Git identity configuration
|
||||||
|
- Default editor preference
|
||||||
|
|
||||||
|
### SSH Key Management
|
||||||
|
- Generate Ed25519 key pairs
|
||||||
|
- Copy public keys to clipboard
|
||||||
|
- Delete keys
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- Git
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
1. **Clone the repository:**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd headquarter
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Set up environment:**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start services:**
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Access the application:**
|
||||||
|
- Frontend: http://localhost:5173
|
||||||
|
- API: http://localhost:8000
|
||||||
|
- API Docs: http://localhost:8000/docs
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
See [Deployment Guide](docs/deployment/) for production setup with Traefik and Authentik.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **FastAPI** - Python web framework
|
||||||
|
- **SQLAlchemy** - ORM with async PostgreSQL support
|
||||||
|
- **Pydantic** - Data validation
|
||||||
|
- **Alembic** - Database migrations
|
||||||
|
- **python-jose** - JWT handling
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **React** - UI library
|
||||||
|
- **TypeScript** - Type safety
|
||||||
|
- **Vite** - Build tool
|
||||||
|
- **React Router** - Client-side routing
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- **Docker** - Containerization
|
||||||
|
- **PostgreSQL** - Database
|
||||||
|
- **Traefik** - Reverse proxy (production)
|
||||||
|
- **Authentik** - Identity provider
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [User Guide](docs/features/) - Feature documentation
|
||||||
|
- [API Reference](docs/api/) - API endpoints
|
||||||
|
- [Architecture](docs/architecture/) - System design
|
||||||
|
- [Deployment](docs/deployment/) - Setup guides
|
||||||
|
- [Development](docs/development/) - Contributing
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── apps/
|
||||||
|
│ ├── api/ # FastAPI backend
|
||||||
|
│ │ ├── src/
|
||||||
|
│ │ │ ├── api/ # API routes
|
||||||
|
│ │ │ ├── auth/ # Authentication
|
||||||
|
│ │ │ ├── models/ # Database models
|
||||||
|
│ │ │ └── utils/ # Utilities
|
||||||
|
│ │ ├── tests/ # Test suite
|
||||||
|
│ │ └── Dockerfile
|
||||||
|
│ └── web/ # React frontend
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # API clients
|
||||||
|
│ │ ├── components/# UI components
|
||||||
|
│ │ └── pages/ # Page components
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── docker-compose.yml # Development setup
|
||||||
|
├── docker-compose.traefik.yml # Production setup
|
||||||
|
└── Makefile # Common commands
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test Markers
|
## Development
|
||||||
|
|
||||||
Tests are marked with pytest markers:
|
### Backend Development
|
||||||
- `@pytest.mark.unit` - Fast, isolated tests
|
```bash
|
||||||
- `@pytest.mark.integration` - Tests with database/external services
|
cd apps/api
|
||||||
- `@pytest.mark.system` - Full stack tests
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
uvicorn src.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
### Shared Fixtures
|
### Frontend Development
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
Common fixtures are in `apps/api/tests/conftest.py`:
|
### Running Tests
|
||||||
- `sqlite_engine` - SQLite engine for unit tests
|
```bash
|
||||||
- `postgres_engine` - PostgreSQL engine for integration tests
|
# Backend tests
|
||||||
- `db_session` - Database session with transaction rollback
|
make test
|
||||||
- `test_client` - FastAPI TestClient instance
|
|
||||||
|
# Frontend tests
|
||||||
|
make test-web
|
||||||
|
|
||||||
|
# All quality gates
|
||||||
|
make lint
|
||||||
|
make typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Key environment variables:
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `API_DOMAIN` | API domain | `localhost` |
|
||||||
|
| `WEB_DOMAIN` | Web domain | `localhost` |
|
||||||
|
| `AUTHENTIK_DOMAIN` | Authentik domain | - |
|
||||||
|
| `AUTHENTIK_CLIENT_ID` | OAuth client ID | - |
|
||||||
|
| `AUTHENTIK_CLIENT_SECRET` | OAuth client secret | - |
|
||||||
|
| `DATABASE_URL` | PostgreSQL URL | - |
|
||||||
|
| `JWT_SECRET` | JWT signing secret | - |
|
||||||
|
| `REPO_BASE_PATH` | Repository storage path | `/data/repos` |
|
||||||
|
|
||||||
|
See [Environment Variables](docs/deployment/environment.md) for complete list.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[License information]
|
||||||
|
|||||||
+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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+26
-9
@@ -16,34 +16,51 @@ RUN pip install --no-cache-dir --user -e ".[dev]"
|
|||||||
# Production stage
|
# Production stage
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user and add to docker group
|
||||||
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
|
RUN groupadd -r appgroup && useradd -r -g appgroup appuser \
|
||||||
|
&& groupadd -r docker || true \
|
||||||
|
&& usermod -aG docker appuser
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install runtime dependencies
|
# Install runtime dependencies including Docker CLI
|
||||||
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 \
|
||||||
|
curl \
|
||||||
|
gnupg \
|
||||||
|
&& install -m 0755 -d /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||||
|
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
||||||
|
&& echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
|
||||||
|
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" > /etc/apt/sources.list.d/docker.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||||
|
&& curl -L --output /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
|
||||||
|
&& chmod +x /usr/local/bin/cloudflared \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy dependencies from builder
|
# Copy dependencies from builder
|
||||||
COPY --from=builder /root/.local /home/appuser/.local
|
COPY --from=builder /root/.local /root/.local
|
||||||
ENV PATH=/home/appuser/.local/bin:$PATH
|
ENV PATH=/root/.local/bin:$PATH
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY --chown=appuser:appgroup . .
|
COPY --chown=appuser:appgroup . .
|
||||||
|
|
||||||
# Create directories for repo storage
|
# Create directories for repo, instance, and workspace storage
|
||||||
RUN mkdir -p /data/repos && chown -R appuser:appgroup /data/repos
|
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
|
||||||
RUN chmod +x /usr/local/bin/wait-for-db.sh
|
RUN chmod +x /usr/local/bin/wait-for-db.sh
|
||||||
|
|
||||||
# Switch to non-root user
|
# NOTE: Running as root to access Docker socket for managing tool instances
|
||||||
USER appuser
|
# This is required because Docker socket permissions require root or docker group membership
|
||||||
|
# which doesn't work well across container boundaries.
|
||||||
|
# Consider using Docker-in-Docker or rootless Docker for production hardening.
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# Headquarter API
|
||||||
|
|
||||||
|
The backend API for Headquarter - a self-hosted platform for managing projects, git repositories, and development tools.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Built with **FastAPI** and **SQLAlchemy** (async), using **PostgreSQL** for data storage and **Docker** for tool instance management.
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: FastAPI (Python 3.12+)
|
||||||
|
- **Database**: PostgreSQL 15+ with asyncpg
|
||||||
|
- **ORM**: SQLAlchemy 2.0 (async)
|
||||||
|
- **Auth**: OAuth2 via Authentik with session cookies
|
||||||
|
- **Migrations**: Alembic
|
||||||
|
- **Tools**: Docker Compose for instance management
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- PostgreSQL 15+ running locally
|
||||||
|
- Docker (for tool instances)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/api
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Set up database
|
||||||
|
# Ensure PostgreSQL is running with a 'headquarter' database
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
uvicorn src.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at `http://localhost:8000`.
|
||||||
|
|
||||||
|
### Interactive Documentation
|
||||||
|
|
||||||
|
Once running, visit:
|
||||||
|
- **Swagger UI**: http://localhost:8000/docs
|
||||||
|
- **ReDoc**: http://localhost:8000/redoc
|
||||||
|
- **OpenAPI JSON**: http://localhost:8000/openapi.json
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `DATABASE_URL` | Yes | - | PostgreSQL connection string |
|
||||||
|
| `API_BASE_URL` | Yes | - | Public API URL (e.g., `https://api.example.com`) |
|
||||||
|
| `AUTHENTIK_DOMAIN` | Yes | - | Authentik server domain |
|
||||||
|
| `AUTHENTIK_CLIENT_ID` | Yes | - | OAuth2 client ID |
|
||||||
|
| `AUTHENTIK_CLIENT_SECRET` | Yes | - | OAuth2 client secret |
|
||||||
|
| `AUTHENTIK_APPLICATION_SLUG` | Yes | - | Authentik application slug |
|
||||||
|
| `WEB_BASE_URL` | Yes | - | Public frontend URL |
|
||||||
|
| `SESSION_SECRET` | Yes | - | Secret for session cookie signing |
|
||||||
|
| `COOKIE_DOMAIN` | No | - | Cookie domain (e.g., `.example.com`) |
|
||||||
|
| `UPLOAD_DIR` | No | `./uploads` | Directory for file uploads |
|
||||||
|
| `REPO_BASE_PATH` | No | `./repositories` | Base path for git repositories |
|
||||||
|
| `INSTANCES_BASE_PATH` | No | `./instances` | Base path for tool instances |
|
||||||
|
| `LOG_LEVEL` | No | `INFO` | Logging level |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run specific test category
|
||||||
|
pytest -m unit # Unit tests (no DB)
|
||||||
|
pytest -m integration # Integration tests (requires DB)
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pytest --cov=src --cov-report=html
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Format code
|
||||||
|
ruff format src tests
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
ruff check src tests
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
mypy src
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create new migration
|
||||||
|
alembic revision --autogenerate -m "description"
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# Rollback one migration
|
||||||
|
alembic downgrade -1
|
||||||
|
|
||||||
|
# Show current revision
|
||||||
|
alembic current
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # API endpoint routers
|
||||||
|
│ ├── auth.py # OAuth2 authentication
|
||||||
|
│ ├── dashboard.py # Dashboard summary
|
||||||
|
│ ├── git_repositories.py # Git repo management
|
||||||
|
│ ├── health.py # Health checks
|
||||||
|
│ ├── projects.py # Project CRUD
|
||||||
|
│ ├── ssh_keys.py # SSH key management
|
||||||
|
│ ├── terminal.py # WebSocket terminal
|
||||||
|
│ ├── tool_instances.py # Tool instance management
|
||||||
|
│ ├── tool_types.py # Tool type definitions
|
||||||
|
│ ├── user_config.py # User preferences
|
||||||
|
│ └── users.py # User profile
|
||||||
|
├── auth/ # Authentication logic
|
||||||
|
│ ├── cookies.py # Cookie utilities
|
||||||
|
│ ├── dependencies.py # Auth dependencies
|
||||||
|
│ ├── oidc.py # OpenID Connect
|
||||||
|
│ └── session.py # Session management
|
||||||
|
├── config.py # Application settings
|
||||||
|
├── database.py # Database setup
|
||||||
|
├── main.py # FastAPI application
|
||||||
|
├── models/ # SQLAlchemy models
|
||||||
|
├── schemas/ # Pydantic schemas
|
||||||
|
├── services/ # Business logic
|
||||||
|
│ ├── docker.py # Docker Compose management
|
||||||
|
│ ├── terminal_manager.py # Terminal sessions
|
||||||
|
│ └── terminal_session.py # Terminal I/O
|
||||||
|
└── utils/ # Utilities
|
||||||
|
├── git_control.py # Git operations
|
||||||
|
├── git_files.py # File operations
|
||||||
|
├── git_history.py # History extraction
|
||||||
|
└── git_url_parser.py # URL parsing
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
1. User clicks "Login" → redirects to Authentik OAuth
|
||||||
|
2. Authentik redirects back with authorization code
|
||||||
|
3. API exchanges code for tokens and fetches user info
|
||||||
|
4. API creates session cookie (HMAC-signed, httpOnly)
|
||||||
|
5. Frontend stores nothing - cookie sent automatically
|
||||||
|
6. Subsequent requests include cookie for authentication
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → FastAPI Router → Auth Dependency → Service Layer → Database
|
||||||
|
↓
|
||||||
|
Pydantic Models (validation)
|
||||||
|
↓
|
||||||
|
SQLAlchemy Models (ORM)
|
||||||
|
↓
|
||||||
|
PostgreSQL (storage)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `GET /auth/login` - Initiate OAuth login
|
||||||
|
- `GET /auth/callback` - OAuth callback
|
||||||
|
- `GET /auth/me` - Get current user
|
||||||
|
- `POST /auth/logout` - Logout
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
- `GET /projects` - List projects
|
||||||
|
- `POST /projects` - Create project
|
||||||
|
- `GET /projects/{id}` - Get project
|
||||||
|
- `PUT /projects/{id}` - Update project
|
||||||
|
- `DELETE /projects/{id}` - Delete project
|
||||||
|
|
||||||
|
### Git Repositories
|
||||||
|
- `GET /projects/{id}/repositories` - List repositories
|
||||||
|
- `POST /projects/{id}/repositories` - Create repository
|
||||||
|
- `GET /projects/{id}/repositories/{id}` - Get repository
|
||||||
|
- `DELETE /projects/{id}/repositories/{id}` - Delete repository
|
||||||
|
- `GET /projects/{id}/repositories/{id}/files` - List files
|
||||||
|
- `GET /projects/{id}/repositories/{id}/files/content` - Get file content
|
||||||
|
- `POST /projects/{id}/repositories/{id}/files/content` - Update file
|
||||||
|
- `GET /projects/{id}/repositories/{id}/branches` - List branches
|
||||||
|
- `GET /projects/{id}/repositories/{id}/history` - Commit history
|
||||||
|
- `GET /projects/{id}/repositories/{id}/commits/{hash}` - Commit detail
|
||||||
|
|
||||||
|
### Tool Types
|
||||||
|
- `GET /tool-types` - List tool types
|
||||||
|
- `POST /tool-types` - Create tool type
|
||||||
|
- `GET /tool-types/{id}` - Get tool type
|
||||||
|
- `PUT /tool-types/{id}` - Update tool type
|
||||||
|
- `DELETE /tool-types/{id}` - Delete tool type
|
||||||
|
|
||||||
|
### Tool Instances
|
||||||
|
- `GET /tool-instances` - List instances
|
||||||
|
- `POST /tool-instances` - Create instance
|
||||||
|
- `GET /tool-instances/{id}` - Get instance
|
||||||
|
- `POST /tool-instances/{id}/start` - Start instance
|
||||||
|
- `POST /tool-instances/{id}/stop` - Stop instance
|
||||||
|
- `POST /tool-instances/{id}/restart` - Restart instance
|
||||||
|
- `DELETE /tool-instances/{id}` - Delete instance
|
||||||
|
- `GET /tool-instances/{id}/logs` - Get logs
|
||||||
|
|
||||||
|
### Terminal
|
||||||
|
- `WS /ws/tool-instances/{id}/terminal` - WebSocket terminal
|
||||||
|
|
||||||
|
### Users
|
||||||
|
- `GET /users/me` - Get profile
|
||||||
|
- `PUT /users/me` - Update profile
|
||||||
|
- `POST /users/me/avatar` - Upload avatar
|
||||||
|
- `GET /users/me/config` - Get config
|
||||||
|
- `PATCH /users/me/config` - Update config
|
||||||
|
|
||||||
|
### SSH Keys
|
||||||
|
- `GET /ssh-keys` - List keys
|
||||||
|
- `POST /ssh-keys` - Create key
|
||||||
|
- `DELETE /ssh-keys/{id}` - Delete key
|
||||||
|
|
||||||
|
### Health
|
||||||
|
- `GET /health` - System health
|
||||||
|
- `GET /health/db` - Database health
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
See the [deployment documentation](../../docs/deployment/) for Docker and Traefik setup.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Follow PEP 8 style guide
|
||||||
|
2. Add tests for new endpoints
|
||||||
|
3. Update documentation
|
||||||
|
4. Run quality gates before committing
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""add tool_instances table
|
||||||
|
|
||||||
|
Revision ID: 0006_tool_instances
|
||||||
|
Revises: 0005_ssh_keys_timestamps
|
||||||
|
Create Date: 2026-05-19 10: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 = "0006_tool_instances"
|
||||||
|
down_revision: Union[str, None] = "0005_ssh_keys_timestamps"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("repository_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("status", sa.String(50), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("container_id", sa.String(255), nullable=True),
|
||||||
|
sa.Column("compose_path", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("url", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("port", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("last_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_stopped_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.ForeignKeyConstraint(["tool_type_id"], ["tool_types.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["repository_id"], ["git_repositories.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_tool_instances_owner", "tool_instances", ["owner_id"])
|
||||||
|
op.create_index("idx_tool_instances_repo", "tool_instances", ["repository_id"])
|
||||||
|
op.create_index("idx_tool_instances_status", "tool_instances", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_tool_instances_status", table_name="tool_instances")
|
||||||
|
op.drop_index("idx_tool_instances_repo", table_name="tool_instances")
|
||||||
|
op.drop_index("idx_tool_instances_owner", table_name="tool_instances")
|
||||||
|
op.drop_table("tool_instances")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add container_name to tool_instances
|
||||||
|
|
||||||
|
Revision ID: 0007_instance_container_name
|
||||||
|
Revises: 0006_tool_instances
|
||||||
|
Create Date: 2026-05-20 08:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0007_instance_container_name"
|
||||||
|
down_revision: Union[str, None] = "0006_tool_instances"
|
||||||
|
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("container_name", sa.String(255), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "container_name")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add category and interfaces to tool_types
|
||||||
|
|
||||||
|
Revision ID: 0008_tool_type_category
|
||||||
|
Revises: 0007_instance_container_name
|
||||||
|
Create Date: 2026-05-20 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0008_tool_type_category"
|
||||||
|
down_revision: Union[str, None] = "0007_instance_container_name"
|
||||||
|
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("category", sa.String(50), nullable=False, server_default="other")
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tool_types",
|
||||||
|
sa.Column("interfaces", sa.JSON(), nullable=False, server_default='["web"]')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_types", "interfaces")
|
||||||
|
op.drop_column("tool_types", "category")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""add tool_configs table
|
||||||
|
|
||||||
|
Revision ID: 0009_tool_configs
|
||||||
|
Revises: 0008_tool_type_category
|
||||||
|
Create Date: 2026-05-20 09: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 = "0009_tool_configs"
|
||||||
|
down_revision: Union[str, None] = "0008_tool_type_category"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tool_configs",
|
||||||
|
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), nullable=False),
|
||||||
|
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("key", sa.String(255), nullable=False),
|
||||||
|
sa.Column("value", sa.Text(), nullable=False),
|
||||||
|
sa.Column("config_type", sa.String(20), nullable=False, server_default="env"),
|
||||||
|
sa.Column("file_path", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["tool_type_id"], ["tool_types.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_tool_configs_user_tool", "tool_configs", ["user_id", "tool_type_id"])
|
||||||
|
op.create_index("idx_tool_configs_project", "tool_configs", ["project_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_tool_configs_project", table_name="tool_configs")
|
||||||
|
op.drop_index("idx_tool_configs_user_tool", table_name="tool_configs")
|
||||||
|
op.drop_table("tool_configs")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add default_port to tool_types
|
||||||
|
|
||||||
|
Revision ID: 0010_tool_type_default_port
|
||||||
|
Revises: 0009_tool_configs
|
||||||
|
Create Date: 2026-05-20 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0010_tool_type_default_port"
|
||||||
|
down_revision: Union[str, None] = "0009_tool_configs"
|
||||||
|
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("default_port", sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_types", "default_port")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add tunnel fields to tool_instances
|
||||||
|
|
||||||
|
Revision ID: 0011_tool_instance_tunnel_fields
|
||||||
|
Revises: 0010_tool_type_default_port
|
||||||
|
Create Date: 2026-05-20 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0011_tool_instance_tunnel_fields"
|
||||||
|
down_revision: Union[str, None] = "0010_tool_type_default_port"
|
||||||
|
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("public_url", sa.String(1024), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("tunnel_id", sa.String(255), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "tunnel_id")
|
||||||
|
op.drop_column("tool_instances", "public_url")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""make default_port non-nullable and set values
|
||||||
|
|
||||||
|
Revision ID: 0012_default_port_req
|
||||||
|
Revises: 0011_tool_instance_tunnel_fields
|
||||||
|
Create Date: 2026-05-20 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0012_default_port_req"
|
||||||
|
down_revision: Union[str, None] = "0011_tool_instance_tunnel_fields"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Set default_port for existing built-in tool types
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET default_port = CASE
|
||||||
|
WHEN name = 'code-server' THEN 8443
|
||||||
|
WHEN name = 'jupyter-notebook' THEN 8888
|
||||||
|
WHEN name = 'opencode' THEN 3000
|
||||||
|
ELSE 8080
|
||||||
|
END
|
||||||
|
WHERE default_port IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Make default_port non-nullable
|
||||||
|
op.alter_column(
|
||||||
|
"tool_types",
|
||||||
|
"default_port",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"tool_types",
|
||||||
|
"default_port",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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,40 @@
|
|||||||
|
"""add_tool_config_fields
|
||||||
|
|
||||||
|
Revision ID: 398082499c30
|
||||||
|
Revises: af8512103d67
|
||||||
|
Create Date: 2026-05-22 18:38:20.166184
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '398082499c30'
|
||||||
|
down_revision = 'af8512103d67'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add new columns to tool_configs
|
||||||
|
op.add_column('tool_configs', sa.Column('port_override', sa.Integer(), nullable=True))
|
||||||
|
op.add_column('tool_configs', sa.Column('start_command', sa.Text(), nullable=True))
|
||||||
|
op.add_column('tool_configs', sa.Column('working_directory', sa.Text(), nullable=True))
|
||||||
|
op.add_column('tool_configs', sa.Column('environment_variables', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
|
||||||
|
op.add_column('tool_configs', sa.Column('volumes', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='[]'))
|
||||||
|
|
||||||
|
# Add CHECK constraint for port range
|
||||||
|
op.create_check_constraint('chk_port_range', 'tool_configs', sa.text('port_override IS NULL OR (port_override >= 1 AND port_override <= 65535)'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop CHECK constraint
|
||||||
|
op.drop_constraint('chk_port_range', 'tool_configs', type_='check')
|
||||||
|
|
||||||
|
# Drop columns
|
||||||
|
op.drop_column('tool_configs', 'port_override')
|
||||||
|
op.drop_column('tool_configs', 'start_command')
|
||||||
|
op.drop_column('tool_configs', 'working_directory')
|
||||||
|
op.drop_column('tool_configs', 'environment_variables')
|
||||||
|
op.drop_column('tool_configs', 'volumes')
|
||||||
@@ -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,44 @@
|
|||||||
|
"""create_config_folders_table
|
||||||
|
|
||||||
|
Revision ID: 8ed7dd80973d
|
||||||
|
Revises: 398082499c30
|
||||||
|
Create Date: 2026-05-22 18:38:22.133696
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '8ed7dd80973d'
|
||||||
|
down_revision = '398082499c30'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'config_folders',
|
||||||
|
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||||
|
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('mount_path', sa.String(1024), nullable=False),
|
||||||
|
sa.Column('files', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
|
||||||
|
sa.Column('project_overrides', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
|
||||||
|
sa.UniqueConstraint('user_id', 'name', name='uq_config_folders_user_name')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add index on user_id for filtering
|
||||||
|
op.create_index('idx_config_folders_user', 'config_folders', ['user_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop index
|
||||||
|
op.drop_index('idx_config_folders_user', table_name='config_folders')
|
||||||
|
|
||||||
|
# Drop table
|
||||||
|
op.drop_table('config_folders')
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""add_tool_type_fields
|
||||||
|
|
||||||
|
Revision ID: af8512103d67
|
||||||
|
Revises: 0012_default_port_req
|
||||||
|
Create Date: 2026-05-22 18:37:56.607240
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'af8512103d67'
|
||||||
|
down_revision = '0012_default_port_req'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add new columns to tool_types
|
||||||
|
op.add_column('tool_types', sa.Column('definition_type', sa.String(20), nullable=False, server_default='compose'))
|
||||||
|
op.add_column('tool_types', sa.Column('dockerfile_template', sa.Text(), nullable=True))
|
||||||
|
op.add_column('tool_types', sa.Column('build_context', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
|
||||||
|
op.add_column('tool_types', sa.Column('readiness_probe', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||||
|
|
||||||
|
# Add CHECK constraint for definition_type
|
||||||
|
op.create_check_constraint('chk_definition_type', 'tool_types', sa.text("definition_type IN ('compose', 'dockerfile')"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop CHECK constraint
|
||||||
|
op.drop_constraint('chk_definition_type', 'tool_types', type_='check')
|
||||||
|
|
||||||
|
# Drop columns
|
||||||
|
op.drop_column('tool_types', 'definition_type')
|
||||||
|
op.drop_column('tool_types', 'dockerfile_template')
|
||||||
|
op.drop_column('tool_types', 'build_context')
|
||||||
|
op.drop_column('tool_types', 'readiness_probe')
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
|
from src.api.events import router as events_router
|
||||||
|
from src.api.notifications import router as notifications_router
|
||||||
from src.api.users import router as users_router
|
from src.api.users import router as users_router
|
||||||
|
|
||||||
__all__ = ["auth_router", "users_router"]
|
__all__ = ["auth_router", "events_router", "notifications_router", "users_router"]
|
||||||
|
|||||||
+41
-17
@@ -25,8 +25,21 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get(
|
||||||
|
"/login",
|
||||||
|
summary="Initiate OAuth login",
|
||||||
|
description="Redirects to the configured OAuth provider (Authentik) to start the authentication flow.",
|
||||||
|
response_class=RedirectResponse,
|
||||||
|
)
|
||||||
async def login(next: str = "/") -> RedirectResponse:
|
async def login(next: str = "/") -> RedirectResponse:
|
||||||
|
"""Initiate OAuth2 login flow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
next: URL to redirect to after successful authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RedirectResponse to the OAuth provider's authorization endpoint.
|
||||||
|
"""
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||||
state = token_urlsafe(24)
|
state = token_urlsafe(24)
|
||||||
@@ -35,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
|
|||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
state=state,
|
state=state,
|
||||||
)
|
)
|
||||||
logger.info("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
logger.debug("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
||||||
response = RedirectResponse(location)
|
response = RedirectResponse(location)
|
||||||
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
||||||
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
||||||
@@ -46,12 +59,11 @@ async def login(next: str = "/") -> RedirectResponse:
|
|||||||
async def callback(
|
async def callback(
|
||||||
code: str,
|
code: str,
|
||||||
state: str,
|
state: str,
|
||||||
response: Response,
|
|
||||||
auth_state: str | None = Cookie(default=None),
|
auth_state: str | None = Cookie(default=None),
|
||||||
auth_next: str | None = Cookie(default="/"),
|
auth_next: str | None = Cookie(default="/"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
logger.info("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
logger.debug("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
||||||
|
|
||||||
if auth_state is None or auth_state != state:
|
if auth_state is None or auth_state != state:
|
||||||
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
||||||
@@ -59,7 +71,7 @@ async def callback(
|
|||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||||
logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
logger.debug("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
@@ -80,7 +92,7 @@ async def callback(
|
|||||||
access_token=token_payload["access_token"],
|
access_token=token_payload["access_token"],
|
||||||
client=client,
|
client=client,
|
||||||
)
|
)
|
||||||
logger.info("User info fetched successfully")
|
logger.debug("User info fetched successfully")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("User info fetch failed: %s", exc)
|
logger.error("User info fetch failed: %s", exc)
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
||||||
@@ -88,19 +100,19 @@ async def callback(
|
|||||||
authentik_id = str(user_info.get("sub", ""))
|
authentik_id = str(user_info.get("sub", ""))
|
||||||
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
||||||
name = str(user_info.get("name", email))
|
name = str(user_info.get("name", email))
|
||||||
logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
logger.debug("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
||||||
if user is None:
|
if user is None:
|
||||||
logger.info("Creating new user: authentik_id=%s", authentik_id)
|
logger.debug("Creating new user: authentik_id=%s", authentik_id)
|
||||||
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
logger.info("New user created: id=%s", user.id)
|
logger.info("New user created: id=%s", user.id)
|
||||||
else:
|
else:
|
||||||
logger.info("Existing user found: id=%s, updating info", user.id)
|
logger.debug("Existing user found: id=%s, updating info", user.id)
|
||||||
user.email = email
|
user.email = email
|
||||||
user.name = name
|
user.name = name
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -116,7 +128,13 @@ async def callback(
|
|||||||
cookie_secure = bool(cookie_options["secure"])
|
cookie_secure = bool(cookie_options["secure"])
|
||||||
cookie_domain = str(cookie_options["domain"]) if cookie_options.get("domain") else None
|
cookie_domain = str(cookie_options["domain"]) if cookie_options.get("domain") else None
|
||||||
|
|
||||||
response.set_cookie(
|
logger.info("Auth callback complete for user id=%s, redirecting to %s", user.id, auth_next)
|
||||||
|
|
||||||
|
# Redirect to frontend with the original next path
|
||||||
|
redirect_url = f"{settings.web_base_url}{auth_next}"
|
||||||
|
redirect_response = RedirectResponse(url=redirect_url)
|
||||||
|
|
||||||
|
redirect_response.set_cookie(
|
||||||
"session",
|
"session",
|
||||||
session_cookie,
|
session_cookie,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
@@ -124,14 +142,10 @@ async def callback(
|
|||||||
secure=cookie_secure,
|
secure=cookie_secure,
|
||||||
domain=cookie_domain,
|
domain=cookie_domain,
|
||||||
)
|
)
|
||||||
response.delete_cookie("auth_state", samesite="lax", domain=cookie_domain)
|
redirect_response.delete_cookie("auth_state", samesite="lax", domain=cookie_domain)
|
||||||
response.delete_cookie("auth_next", samesite="lax", domain=cookie_domain)
|
redirect_response.delete_cookie("auth_next", samesite="lax", domain=cookie_domain)
|
||||||
|
|
||||||
logger.info("Auth callback complete for user id=%s, redirecting to %s", user.id, auth_next)
|
return redirect_response
|
||||||
|
|
||||||
# Redirect to frontend with the original next path
|
|
||||||
redirect_url = f"{settings.web_base_url}{auth_next}"
|
|
||||||
return RedirectResponse(url=redirect_url)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
@@ -151,20 +165,30 @@ async def me(
|
|||||||
session_cookie: str | None = Cookie(default=None, alias="session"),
|
session_cookie: str | None = Cookie(default=None, alias="session"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
logger.debug("Auth /me called, cookie present: %s", bool(session_cookie))
|
||||||
|
|
||||||
if not session_cookie:
|
if not session_cookie:
|
||||||
|
logger.warning("Auth /me: missing session cookie")
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
logger.debug("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
||||||
|
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
user_id = payload["user_id"]
|
user_id = payload["user_id"]
|
||||||
|
logger.debug("Auth /me: decoded session for user_id=%s", user_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
logger.warning("Auth /me: invalid session: %s", exc)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
||||||
|
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
|
logger.warning("Auth /me: user not found for id=%s", user_id)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
|
||||||
|
logger.info("Auth /me: success for user=%s", user.email)
|
||||||
return {
|
return {
|
||||||
"user": {
|
"user": {
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,11 +12,24 @@ from src.models.ssh_key import SSHKey
|
|||||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary")
|
@router.get(
|
||||||
|
"/summary",
|
||||||
|
summary="Get dashboard summary",
|
||||||
|
description="Get a summary of the user's projects, repositories, SSH keys, and recent activity.",
|
||||||
|
)
|
||||||
async def get_dashboard_summary(
|
async def get_dashboard_summary(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
"""Get a summary of the user's dashboard data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with counts of projects, repositories, SSH keys, and recent activity.
|
||||||
|
"""
|
||||||
# Count user's projects
|
# Count user's projects
|
||||||
projects_result = await session.execute(
|
projects_result = await session.execute(
|
||||||
select(func.count()).select_from(Project).where(Project.owner_id == user_id)
|
select(func.count()).select_from(Project).where(Project.owner_id == user_id)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""SSE streaming endpoint for instance events."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id
|
||||||
|
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/events", tags=["events"])
|
||||||
|
|
||||||
|
# In-memory connection counter per user (single-process assumption)
|
||||||
|
_connection_counts: dict[uuid.UUID, int] = {}
|
||||||
|
MAX_CONNECTIONS_PER_USER = 20
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream")
|
||||||
|
async def events_stream(
|
||||||
|
request: Request,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""Stream instance events via Server-Sent Events.
|
||||||
|
|
||||||
|
Enforces a maximum of 5 concurrent connections per user.
|
||||||
|
"""
|
||||||
|
current = _connection_counts.get(user_id, 0)
|
||||||
|
if current >= MAX_CONNECTIONS_PER_USER:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Too many SSE connections",
|
||||||
|
)
|
||||||
|
|
||||||
|
_connection_counts[user_id] = current + 1
|
||||||
|
|
||||||
|
async def event_generator() -> AsyncGenerator[str, None]:
|
||||||
|
event_bus = InstanceEventBus()
|
||||||
|
queue: asyncio.Queue[InstanceEventPayload] = asyncio.Queue(maxsize=100)
|
||||||
|
|
||||||
|
async def on_event(payload: InstanceEventPayload) -> None:
|
||||||
|
try:
|
||||||
|
queue.put_nowait(payload)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
# Drop oldest event to make room
|
||||||
|
with contextlib.suppress(asyncio.QueueEmpty):
|
||||||
|
queue.get_nowait()
|
||||||
|
with contextlib.suppress(asyncio.QueueFull):
|
||||||
|
queue.put_nowait(payload)
|
||||||
|
|
||||||
|
unsubscribe = event_bus.subscribe("*", on_event)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
payload = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||||
|
yield f"event: {payload['event']}\ndata: {json.dumps(payload)}\n\n"
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
yield ":ping\n\n"
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Client disconnected
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
unsubscribe()
|
||||||
|
_connection_counts[user_id] = max(0, _connection_counts.get(user_id, 1) - 1)
|
||||||
|
if _connection_counts[user_id] == 0:
|
||||||
|
_connection_counts.pop(user_id, None)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
+1262
-107
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
|||||||
|
"""Health check endpoints and models."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from src.database import SessionLocal
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Track start time for uptime
|
||||||
|
_start_time = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseHealth(BaseModel):
|
||||||
|
"""Database health check result."""
|
||||||
|
|
||||||
|
status: str = Field(description="Database health status", examples=["healthy"])
|
||||||
|
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
|
||||||
|
|
||||||
|
|
||||||
|
class DiskHealth(BaseModel):
|
||||||
|
"""Disk space health check result."""
|
||||||
|
|
||||||
|
status: str = Field(description="Disk health status", examples=["healthy"])
|
||||||
|
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
|
||||||
|
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
|
||||||
|
|
||||||
|
|
||||||
|
class HealthChecks(BaseModel):
|
||||||
|
"""Individual health checks."""
|
||||||
|
|
||||||
|
database: DatabaseHealth | None = None
|
||||||
|
disk: DiskHealth | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""Overall health check response."""
|
||||||
|
|
||||||
|
status: str = Field(description="Overall health status", examples=["healthy"])
|
||||||
|
timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"])
|
||||||
|
version: str = Field(description="API version", examples=["0.1.0"])
|
||||||
|
checks: HealthChecks = Field(description="Individual health checks")
|
||||||
|
uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0])
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseHealthResponse(BaseModel):
|
||||||
|
"""Database-specific health check response."""
|
||||||
|
|
||||||
|
status: str = Field(description="Database health status", examples=["healthy"])
|
||||||
|
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/health",
|
||||||
|
response_model=HealthResponse,
|
||||||
|
summary="Health check",
|
||||||
|
description="Returns overall system health status including database and disk checks.",
|
||||||
|
tags=["Health"],
|
||||||
|
)
|
||||||
|
async def health_check() -> dict[str, Any]:
|
||||||
|
"""Check overall system health.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HealthResponse with status, timestamp, version, checks, and uptime.
|
||||||
|
"""
|
||||||
|
checks = HealthChecks()
|
||||||
|
overall_status = "healthy"
|
||||||
|
|
||||||
|
# Database check
|
||||||
|
try:
|
||||||
|
import time as time_module
|
||||||
|
|
||||||
|
start = time_module.perf_counter()
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await session.execute(text("SELECT 1"))
|
||||||
|
db_time = (time_module.perf_counter() - start) * 1000
|
||||||
|
checks.database = DatabaseHealth(
|
||||||
|
status="healthy",
|
||||||
|
response_time_ms=round(db_time, 2),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
checks.database = DatabaseHealth(
|
||||||
|
status="unhealthy",
|
||||||
|
response_time_ms=0.0,
|
||||||
|
)
|
||||||
|
overall_status = "degraded"
|
||||||
|
|
||||||
|
# Disk check
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
disk = shutil.disk_usage("/")
|
||||||
|
free_gb = disk.free / (1024**3)
|
||||||
|
total_gb = disk.total / (1024**3)
|
||||||
|
disk_status = "healthy" if free_gb > 1.0 else "degraded"
|
||||||
|
if disk_status == "degraded":
|
||||||
|
overall_status = "degraded"
|
||||||
|
checks.disk = DiskHealth(
|
||||||
|
status=disk_status,
|
||||||
|
free_gb=round(free_gb, 2),
|
||||||
|
total_gb=round(total_gb, 2),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
checks.disk = None
|
||||||
|
|
||||||
|
return 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()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/health/db",
|
||||||
|
response_model=DatabaseHealthResponse,
|
||||||
|
summary="Database health check",
|
||||||
|
description="Returns database-specific health status with response time.",
|
||||||
|
tags=["Health"],
|
||||||
|
)
|
||||||
|
async def health_check_db() -> dict[str, Any]:
|
||||||
|
"""Check database health.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DatabaseHealthResponse with status and response time.
|
||||||
|
"""
|
||||||
|
import time as time_module
|
||||||
|
|
||||||
|
try:
|
||||||
|
start = time_module.perf_counter()
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await session.execute(text("SELECT 1"))
|
||||||
|
db_time = (time_module.perf_counter() - start) * 1000
|
||||||
|
return DatabaseHealthResponse(
|
||||||
|
status="healthy",
|
||||||
|
response_time_ms=round(db_time, 2),
|
||||||
|
).model_dump()
|
||||||
|
except Exception:
|
||||||
|
return DatabaseHealthResponse(
|
||||||
|
status="unhealthy",
|
||||||
|
response_time_ms=0.0,
|
||||||
|
).model_dump()
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Instance proxy router for forwarding HTTP requests to running containers."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/instances", tags=["instance-proxy"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _proxy_request(
|
||||||
|
request: Request,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
path: str,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> Response:
|
||||||
|
"""Proxy an HTTP request to a running instance."""
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify ownership
|
||||||
|
if instance.owner_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="not authorized to access this instance",
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.status != "running" or not instance.container_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="instance is not running",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the tool type to find the internal port
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
internal_port = tool_type.default_port if tool_type and tool_type.default_port else instance.port
|
||||||
|
|
||||||
|
# Build target URL using internal port
|
||||||
|
target_url = f"http://{instance.container_name}:{internal_port}"
|
||||||
|
if path:
|
||||||
|
target_url += f"/{path}"
|
||||||
|
|
||||||
|
# Get query string
|
||||||
|
query_string = str(request.query_params)
|
||||||
|
if query_string:
|
||||||
|
target_url += f"?{query_string}"
|
||||||
|
|
||||||
|
# Forward headers (excluding host and cookies)
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
for key, value in request.headers.items():
|
||||||
|
if key.lower() not in ("host", "cookie", "content-length"):
|
||||||
|
headers[key] = value
|
||||||
|
|
||||||
|
# Forward the request
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
body = await request.body()
|
||||||
|
response = await client.request(
|
||||||
|
method=request.method,
|
||||||
|
url=target_url,
|
||||||
|
headers=headers,
|
||||||
|
content=body,
|
||||||
|
follow_redirects=False,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Proxy error to %s: %s", target_url, exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"failed to reach instance: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
response_headers = dict(response.headers)
|
||||||
|
# Remove hop-by-hop headers
|
||||||
|
for header in ("content-encoding", "transfer-encoding", "connection"):
|
||||||
|
response_headers.pop(header, None)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=response_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instance_id}/proxy/{path:path}")
|
||||||
|
@router.post("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.put("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.delete("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.patch("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.head("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.options("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
async def proxy_to_instance(
|
||||||
|
request: Request,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
path: str = "",
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> Response:
|
||||||
|
"""Proxy requests to a running tool instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
instance_id: UUID of the instance.
|
||||||
|
path: The path to proxy to the instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response from the proxied instance.
|
||||||
|
"""
|
||||||
|
return await _proxy_request(request, instance_id, path, user_id, session)
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Notification API endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user, get_db_session
|
||||||
|
from src.models.user import User
|
||||||
|
from src.models.user_config import UserConfig
|
||||||
|
from src.services.notification_service import notification_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationItem(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
category: str
|
||||||
|
severity: str
|
||||||
|
title: str
|
||||||
|
message: str | None
|
||||||
|
source_type: str | None
|
||||||
|
source_id: uuid.UUID | None
|
||||||
|
notification_metadata: dict = Field(serialization_alias="metadata")
|
||||||
|
read_at: datetime | None
|
||||||
|
dismissed_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationListResponse(BaseModel):
|
||||||
|
items: list[NotificationItem]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class UnreadCountResponse(BaseModel):
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class MarkAllReadResponse(BaseModel):
|
||||||
|
marked_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ClearAllResponse(BaseModel):
|
||||||
|
cleared_count: int
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_mute_categories(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Read notification mute categories from user config."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
if config is None:
|
||||||
|
return []
|
||||||
|
mute_categories = config.config.get("notification_mute_categories", [])
|
||||||
|
if isinstance(mute_categories, list):
|
||||||
|
return mute_categories
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=NotificationListResponse)
|
||||||
|
async def list_notifications(
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
unread_only: bool = Query(False),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> NotificationListResponse:
|
||||||
|
"""List notifications for the authenticated user."""
|
||||||
|
mute_categories = await _get_mute_categories(session, user.id)
|
||||||
|
items, total = await notification_service.list_notifications(
|
||||||
|
session,
|
||||||
|
user.id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
unread_only=unread_only,
|
||||||
|
mute_categories=mute_categories,
|
||||||
|
)
|
||||||
|
return NotificationListResponse(
|
||||||
|
items=[NotificationItem.model_validate(item) for item in items],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unread", response_model=UnreadCountResponse)
|
||||||
|
async def get_unread_count(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> UnreadCountResponse:
|
||||||
|
"""Get unread notification count for the authenticated user."""
|
||||||
|
count = await notification_service.get_unread_count(session, user.id)
|
||||||
|
return UnreadCountResponse(count=count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{notification_id}/read", response_model=NotificationItem)
|
||||||
|
async def mark_notification_read(
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> NotificationItem:
|
||||||
|
"""Mark a single notification as read."""
|
||||||
|
try:
|
||||||
|
notification = await notification_service.mark_read(
|
||||||
|
session, notification_id, user.id
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Notification not found",
|
||||||
|
) from exc
|
||||||
|
return NotificationItem.model_validate(notification)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mark-all-read", response_model=MarkAllReadResponse)
|
||||||
|
async def mark_all_read(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> MarkAllReadResponse:
|
||||||
|
"""Mark all unread notifications as read."""
|
||||||
|
marked = await notification_service.mark_all_read(session, user.id)
|
||||||
|
return MarkAllReadResponse(marked_count=marked)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("", status_code=status.HTTP_200_OK)
|
||||||
|
async def clear_all_notifications(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> ClearAllResponse:
|
||||||
|
"""Dismiss all notifications for the authenticated user."""
|
||||||
|
cleared = await notification_service.dismiss_all(session, user.id)
|
||||||
|
return ClearAllResponse(cleared_count=cleared)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def dismiss_notification(
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""Soft-delete (dismiss) a single notification."""
|
||||||
|
try:
|
||||||
|
await notification_service.dismiss(session, notification_id, user.id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Notification not found",
|
||||||
|
) from exc
|
||||||
+168
-28
@@ -4,25 +4,23 @@ import uuid
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
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_id, get_db_session
|
from src.auth.dependencies import (
|
||||||
|
_get_owned_project,
|
||||||
|
_get_user,
|
||||||
|
get_current_user_id,
|
||||||
|
get_db_session,
|
||||||
|
)
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
from src.models.tool_instance import ToolInstance
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|
||||||
user = await session.get(User, user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreate(BaseModel):
|
class ProjectCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -47,12 +45,28 @@ class SetDefaultSSHKeyRequest(BaseModel):
|
|||||||
ssh_key_id: uuid.UUID
|
ssh_key_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=ProjectResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create a new project",
|
||||||
|
description="Create a new project for the authenticated user.",
|
||||||
|
)
|
||||||
async def create_project(
|
async def create_project(
|
||||||
data: ProjectCreate,
|
data: ProjectCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
|
"""Create a new project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Project creation data including name and optional description.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created project.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
project = Project(
|
project = Project(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
@@ -66,36 +80,129 @@ async def create_project(
|
|||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ProjectResponse])
|
@router.get(
|
||||||
|
"",
|
||||||
|
summary="List all projects",
|
||||||
|
description="Retrieve all projects owned by the authenticated user with repositories and workspaces.",
|
||||||
|
)
|
||||||
async def list_projects(
|
async def list_projects(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[Project]:
|
) -> list[dict]:
|
||||||
|
"""List all projects for the authenticated user.
|
||||||
|
|
||||||
|
Returns projects with nested repositories and workspaces for inline display.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
result = await session.execute(
|
||||||
return list(result.scalars().all())
|
select(Project)
|
||||||
|
.where(Project.owner_id == user.id)
|
||||||
|
.order_by(Project.created_at.desc())
|
||||||
|
)
|
||||||
|
projects = result.scalars().all()
|
||||||
|
|
||||||
|
from src.models.workspace 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
|
||||||
|
|
||||||
|
|
||||||
async def _get_owned_project(
|
@router.get(
|
||||||
|
"/{project_id}",
|
||||||
|
response_model=ProjectResponse,
|
||||||
|
summary="Get a project",
|
||||||
|
description="Retrieve a specific project by ID.",
|
||||||
|
)
|
||||||
|
async def get_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession,
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
project = await session.get(Project, project_id)
|
"""Get a specific project by ID.
|
||||||
if project is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
Args:
|
||||||
if project.owner_id != user_id:
|
project_id: UUID of the project to retrieve.
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
user_id: ID of the authenticated user.
|
||||||
return project
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The requested project.
|
||||||
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
return await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{project_id}", response_model=ProjectResponse)
|
@router.patch(
|
||||||
|
"/{project_id}",
|
||||||
|
response_model=ProjectResponse,
|
||||||
|
summary="Update a project",
|
||||||
|
description="Update a project's name or description.",
|
||||||
|
)
|
||||||
async def update_project(
|
async def update_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: ProjectUpdate,
|
data: ProjectUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
|
"""Update a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project to update.
|
||||||
|
data: Project update data with optional name and description.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated project.
|
||||||
|
"""
|
||||||
await _get_user(session, user_id)
|
await _get_user(session, user_id)
|
||||||
project = await _get_owned_project(project_id, user_id, session)
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -109,17 +216,34 @@ async def update_project(
|
|||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete(
|
||||||
|
"/{project_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete a project",
|
||||||
|
description="Delete a project and all its associated repositories.",
|
||||||
|
)
|
||||||
async def delete_project(
|
async def delete_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
|
"""Delete a project and all its repositories.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project to delete.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Empty response with 204 status code.
|
||||||
|
"""
|
||||||
await _get_user(session, user_id)
|
await _get_user(session, user_id)
|
||||||
project = await _get_owned_project(project_id, user_id, session)
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
# Delete repositories from disk and database
|
# Delete repositories from disk and database
|
||||||
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
result = await session.execute(
|
||||||
|
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||||
|
)
|
||||||
repositories = result.scalars().all()
|
repositories = result.scalars().all()
|
||||||
for repo in repositories:
|
for repo in repositories:
|
||||||
if os.path.exists(repo.path):
|
if os.path.exists(repo.path):
|
||||||
@@ -131,13 +255,29 @@ async def delete_project(
|
|||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{project_id}/default-ssh-key", response_model=ProjectResponse)
|
@router.patch(
|
||||||
|
"/{project_id}/default-ssh-key",
|
||||||
|
response_model=ProjectResponse,
|
||||||
|
summary="Set default SSH key",
|
||||||
|
description="Set the default SSH key for a 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_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
|
"""Set the default SSH key for a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
data: Request containing the SSH key ID to set as default.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated project.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
project = await _get_owned_project(project_id, user_id, session)
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+149
-12
@@ -1,3 +1,4 @@
|
|||||||
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -9,21 +10,13 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|
||||||
user = await session.get(User, user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def _get_fernet() -> Fernet:
|
def _get_fernet() -> Fernet:
|
||||||
"""Generate a valid Fernet key from the session secret."""
|
"""Generate a valid Fernet key from the session secret."""
|
||||||
import base64
|
import base64
|
||||||
@@ -38,6 +31,11 @@ def _get_fernet() -> Fernet:
|
|||||||
|
|
||||||
|
|
||||||
def generate_ssh_key_pair() -> tuple[str, str]:
|
def generate_ssh_key_pair() -> tuple[str, str]:
|
||||||
|
"""Generate a new Ed25519 SSH key pair.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (private_key, public_key) as strings.
|
||||||
|
"""
|
||||||
private_key = Ed25519PrivateKey.generate()
|
private_key = Ed25519PrivateKey.generate()
|
||||||
public_key = private_key.public_key()
|
public_key = private_key.public_key()
|
||||||
|
|
||||||
@@ -68,12 +66,45 @@ class SSHKeyResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=SSHKeyResponse, status_code=status.HTTP_201_CREATED)
|
class SignPayloadRequest(BaseModel):
|
||||||
|
payload: str
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureResponse(BaseModel):
|
||||||
|
signature: str
|
||||||
|
|
||||||
|
|
||||||
|
class VerifySignatureRequest(BaseModel):
|
||||||
|
payload: str
|
||||||
|
signature: str
|
||||||
|
|
||||||
|
|
||||||
|
class VerifySignatureResponse(BaseModel):
|
||||||
|
valid: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=SSHKeyResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create SSH key",
|
||||||
|
description="Generate a new Ed25519 SSH key pair for the authenticated user.",
|
||||||
|
)
|
||||||
async def create_ssh_key(
|
async def create_ssh_key(
|
||||||
data: SSHKeyCreate,
|
data: SSHKeyCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> SSHKey:
|
) -> SSHKey:
|
||||||
|
"""Create a new SSH key pair.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: SSH key creation data including the key name.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created SSH key with public key exposed.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
private_key, public_key = generate_ssh_key_pair()
|
private_key, public_key = generate_ssh_key_pair()
|
||||||
|
|
||||||
@@ -92,22 +123,51 @@ async def create_ssh_key(
|
|||||||
return ssh_key
|
return ssh_key
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SSHKeyResponse])
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=list[SSHKeyResponse],
|
||||||
|
summary="List SSH keys",
|
||||||
|
description="List all SSH keys for the authenticated user.",
|
||||||
|
)
|
||||||
async def list_ssh_keys(
|
async def list_ssh_keys(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[SSHKey]:
|
) -> list[SSHKey]:
|
||||||
|
"""List all SSH keys for the authenticated user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of SSH keys owned by the user.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete(
|
||||||
|
"/{key_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete SSH key",
|
||||||
|
description="Delete an SSH key by ID.",
|
||||||
|
)
|
||||||
async def delete_ssh_key(
|
async def delete_ssh_key(
|
||||||
key_id: uuid.UUID,
|
key_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Delete an SSH key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to delete.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None with 204 status code.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
ssh_key = await session.get(SSHKey, key_id)
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
@@ -115,3 +175,80 @@ async def delete_ssh_key(
|
|||||||
|
|
||||||
await session.delete(ssh_key)
|
await session.delete(ssh_key)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{key_id}/sign",
|
||||||
|
response_model=SignatureResponse,
|
||||||
|
summary="Sign payload",
|
||||||
|
description="Sign a payload using the SSH private key.",
|
||||||
|
)
|
||||||
|
async def sign_payload(
|
||||||
|
key_id: uuid.UUID,
|
||||||
|
data: SignPayloadRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> SignatureResponse:
|
||||||
|
"""Sign a payload with an SSH key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to use for signing.
|
||||||
|
data: Sign request containing the payload string.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64-encoded Ed25519 signature.
|
||||||
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
|
private_key = serialization.load_ssh_private_key(
|
||||||
|
private_key_pem.encode(), password=None
|
||||||
|
)
|
||||||
|
|
||||||
|
signature = private_key.sign(data.payload.encode())
|
||||||
|
return SignatureResponse(signature=base64.b64encode(signature).decode())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{key_id}/verify",
|
||||||
|
response_model=VerifySignatureResponse,
|
||||||
|
summary="Verify signature",
|
||||||
|
description="Verify a signature against a payload using the SSH public key.",
|
||||||
|
)
|
||||||
|
async def verify_signature(
|
||||||
|
key_id: uuid.UUID,
|
||||||
|
data: VerifySignatureRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> VerifySignatureResponse:
|
||||||
|
"""Verify a signature with an SSH key's public key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to use for verification.
|
||||||
|
data: Verify request containing payload and base64-encoded signature.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Whether the signature is valid.
|
||||||
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
|
||||||
|
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
||||||
|
|
||||||
|
try:
|
||||||
|
signature = base64.b64decode(data.signature)
|
||||||
|
public_key.verify(signature, data.payload.encode())
|
||||||
|
return VerifySignatureResponse(valid=True)
|
||||||
|
except Exception:
|
||||||
|
return VerifySignatureResponse(valid=False)
|
||||||
|
|||||||
@@ -0,0 +1,749 @@
|
|||||||
|
"""WebSocket terminal endpoint for tool instances."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.terminal_session import TerminalSessionModel
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRef:
|
||||||
|
"""Mutable reference to a terminal session, allowing updates during reset."""
|
||||||
|
|
||||||
|
def __init__(self, session, slot_session_id: str | None = None):
|
||||||
|
self.session = session
|
||||||
|
self.slot_session_id = slot_session_id or session.session_id
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket(
|
||||||
|
"/ws/tool-instances/{instance_id}/terminal",
|
||||||
|
)
|
||||||
|
async def terminal_websocket_default(
|
||||||
|
websocket: WebSocket,
|
||||||
|
instance_id: str,
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""WebSocket endpoint for terminal access (default session alias).
|
||||||
|
|
||||||
|
Backward-compatible route that maps to the default session.
|
||||||
|
"""
|
||||||
|
await _handle_terminal_websocket(websocket, instance_id, None, db_session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket(
|
||||||
|
"/ws/tool-instances/{instance_id}/terminal/{session_id}",
|
||||||
|
)
|
||||||
|
async def terminal_websocket_specific(
|
||||||
|
websocket: WebSocket,
|
||||||
|
instance_id: str,
|
||||||
|
session_id: str,
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""WebSocket endpoint for a specific terminal session."""
|
||||||
|
await _handle_terminal_websocket(websocket, instance_id, session_id, db_session)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_terminal_websocket(
|
||||||
|
websocket: WebSocket,
|
||||||
|
instance_id: str,
|
||||||
|
target_session_id: str | None,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Shared WebSocket handler for terminal sessions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
websocket: The WebSocket connection.
|
||||||
|
instance_id: UUID string of the tool instance.
|
||||||
|
target_session_id: Specific session ID (slot key). None means default session.
|
||||||
|
db_session: Database session.
|
||||||
|
"""
|
||||||
|
logger.debug(
|
||||||
|
"Terminal WebSocket connection attempt for instance %s (session=%s)",
|
||||||
|
instance_id,
|
||||||
|
target_session_id or "default",
|
||||||
|
)
|
||||||
|
await websocket.accept()
|
||||||
|
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse instance_id
|
||||||
|
instance_uuid = uuid.UUID(instance_id)
|
||||||
|
except ValueError:
|
||||||
|
logger.error("Invalid instance ID: %s", instance_id)
|
||||||
|
await websocket.close(code=4001, reason="Invalid instance ID")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Authenticate user from session cookie
|
||||||
|
user_id = await _get_user_from_websocket(websocket, db_session)
|
||||||
|
if user_id is None:
|
||||||
|
logger.warning(
|
||||||
|
"Unauthorized terminal access attempt for instance %s", instance_id
|
||||||
|
)
|
||||||
|
await websocket.close(code=4003, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get instance and verify ownership
|
||||||
|
instance = await db_session.get(ToolInstance, instance_uuid)
|
||||||
|
if instance is None:
|
||||||
|
logger.warning("Instance %s not found", instance_id)
|
||||||
|
await websocket.close(code=4004, reason="Instance not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
if instance.owner_id != user_id:
|
||||||
|
logger.warning(
|
||||||
|
"Forbidden terminal access for instance %s by user %s",
|
||||||
|
instance_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
await websocket.close(code=4003, reason="Forbidden")
|
||||||
|
return
|
||||||
|
|
||||||
|
if instance.status != "running" or not instance.container_id:
|
||||||
|
logger.warning(
|
||||||
|
"Instance %s not running (status=%s, container_id=%s)",
|
||||||
|
instance_id,
|
||||||
|
instance.status,
|
||||||
|
instance.container_id,
|
||||||
|
)
|
||||||
|
await websocket.close(code=4004, reason="Instance not running")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
||||||
|
|
||||||
|
# Verify the container actually exists (may have been removed/recreated)
|
||||||
|
from src.services.docker import get_container_status
|
||||||
|
|
||||||
|
container_status = get_container_status(instance.container_id)
|
||||||
|
if container_status["status"] == "not_found":
|
||||||
|
logger.error(
|
||||||
|
"Container %s for instance %s not found (may have been removed)",
|
||||||
|
instance.container_id,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
await websocket.close(
|
||||||
|
code=4004, reason="Container not found — restart the tool instance"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
if startup_command:
|
||||||
|
logger.debug(
|
||||||
|
"Using startup command for instance %s: %s",
|
||||||
|
instance_id,
|
||||||
|
startup_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
session = None
|
||||||
|
|
||||||
|
# Get or create terminal session
|
||||||
|
try:
|
||||||
|
if target_session_id is None:
|
||||||
|
# Default session alias
|
||||||
|
session = await terminal_manager.get_or_create_session(
|
||||||
|
instance_uuid,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
)
|
||||||
|
slot_session_id = "default"
|
||||||
|
else:
|
||||||
|
# Specific session
|
||||||
|
session = terminal_manager.get_session(
|
||||||
|
instance_id,
|
||||||
|
target_session_id,
|
||||||
|
)
|
||||||
|
if session is None:
|
||||||
|
# Session not in memory — may have been lost on server restart.
|
||||||
|
# Try to restore from the DB row.
|
||||||
|
db_row = await db_session.get(
|
||||||
|
TerminalSessionModel, uuid.UUID(target_session_id)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
db_row is not None
|
||||||
|
and db_row.instance_id == instance_uuid
|
||||||
|
and db_row.status != "closed"
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"Restoring terminal session %s for instance %s from DB",
|
||||||
|
target_session_id,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
session = await terminal_manager.create_session(
|
||||||
|
instance_uuid,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
name=db_row.name,
|
||||||
|
session_id=target_session_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Session %s not found for instance %s",
|
||||||
|
target_session_id,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
await websocket.close(code=4004, reason="Session not found")
|
||||||
|
return
|
||||||
|
# Determine slot key for reset scoping
|
||||||
|
key = terminal_manager._find_key_by_internal_id(
|
||||||
|
instance_id, session.session_id
|
||||||
|
)
|
||||||
|
slot_session_id = key[1] if key else target_session_id
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Terminal session ready for instance %s (session_id=%s, slot=%s)",
|
||||||
|
instance_id,
|
||||||
|
session.session_id,
|
||||||
|
slot_session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach WebSocket to session
|
||||||
|
await terminal_manager.attach_websocket(session, websocket)
|
||||||
|
logger.debug("WebSocket attached to session for instance %s", instance_id)
|
||||||
|
|
||||||
|
# Send connected status
|
||||||
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
logger.debug("Sent connected status for instance %s", instance_id)
|
||||||
|
|
||||||
|
# Use mutable session reference so loops can survive reset
|
||||||
|
session_ref = SessionRef(session, slot_session_id)
|
||||||
|
|
||||||
|
# Start write loop and heartbeat (read is now event-driven in TerminalSession)
|
||||||
|
write_task = asyncio.create_task(
|
||||||
|
_write_loop(session_ref, websocket, instance_id)
|
||||||
|
)
|
||||||
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||||
|
logger.debug("Started terminal loops for instance %s", instance_id)
|
||||||
|
|
||||||
|
# Wait for either task to complete (indicating disconnect or error)
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[write_task, heartbeat_task],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Terminal loop completed for instance %s, done=%s",
|
||||||
|
instance_id,
|
||||||
|
len(done),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel remaining tasks
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("WebSocket disconnected for instance %s", instance_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Terminal session error for instance %s: %s",
|
||||||
|
instance_id,
|
||||||
|
str(exc),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
with suppress(Exception):
|
||||||
|
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||||
|
finally:
|
||||||
|
# Detach WebSocket, don't kill session
|
||||||
|
with suppress(Exception):
|
||||||
|
if session is not None:
|
||||||
|
await terminal_manager.detach_websocket(session, websocket)
|
||||||
|
logger.debug(
|
||||||
|
"WebSocket detached from session for instance %s", instance_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||||
|
"""Read input from WebSocket and send to container."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
session = session_ref.session
|
||||||
|
if not session.is_alive() or session._closed:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
|
message = await websocket.receive()
|
||||||
|
if message["type"] == "websocket.receive":
|
||||||
|
if "bytes" in message:
|
||||||
|
await session.write_input(message["bytes"])
|
||||||
|
elif "text" in message:
|
||||||
|
text = message["text"]
|
||||||
|
if text.startswith("{"):
|
||||||
|
# Control message (JSON)
|
||||||
|
try:
|
||||||
|
ctrl = json.loads(text)
|
||||||
|
msg_type = ctrl.get("type")
|
||||||
|
|
||||||
|
if msg_type == "resize":
|
||||||
|
cols = ctrl.get("cols", 80)
|
||||||
|
rows = ctrl.get("rows", 24)
|
||||||
|
logger.debug(
|
||||||
|
"Received resize message for instance %s: %sx%s",
|
||||||
|
instance_id,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
await session.resize(cols, rows)
|
||||||
|
elif msg_type == "ack":
|
||||||
|
char_count = ctrl.get("chars", 0)
|
||||||
|
if char_count > 0:
|
||||||
|
session.acknowledge_data(char_count)
|
||||||
|
elif msg_type == "reset":
|
||||||
|
# Reset terminal session (scoped to current slot)
|
||||||
|
logger.debug(
|
||||||
|
"Resetting terminal session for instance %s (slot=%s)",
|
||||||
|
session.instance_id,
|
||||||
|
session_ref.slot_session_id,
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "status", "status": "resetting"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reset the session scoped to its slot
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
session.instance_id,
|
||||||
|
session.container_id,
|
||||||
|
startup_command=session.startup_command,
|
||||||
|
session_id=session_ref.slot_session_id,
|
||||||
|
name=session.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the mutable session reference
|
||||||
|
session_ref.session = new_session
|
||||||
|
|
||||||
|
# Attach to new session
|
||||||
|
await terminal_manager.attach_websocket(
|
||||||
|
new_session, websocket
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "status", "status": "connected"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Continue the loop with the new session
|
||||||
|
continue
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Not a valid JSON control message, treat as regular input
|
||||||
|
await session.write_input(text.encode("utf-8"))
|
||||||
|
else:
|
||||||
|
await session.write_input(text.encode("utf-8"))
|
||||||
|
elif message["type"] == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
||||||
|
"""Send periodic ping messages to detect disconnections."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30) # Ping every 30 seconds
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "ping"})
|
||||||
|
except Exception:
|
||||||
|
# WebSocket is closed or broken
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_terminal_instance(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> ToolInstance:
|
||||||
|
"""Fetch instance and validate auth, ownership, and running status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated ToolInstance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If instance not found, not owned, or not running.
|
||||||
|
"""
|
||||||
|
instance = await db_session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.owner_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to access this instance",
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.status != "running" or not instance.container_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Instance is not running"
|
||||||
|
)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/instances/{instance_id}/terminal/sessions",
|
||||||
|
summary="List terminal sessions",
|
||||||
|
description="List terminal sessions for a tool instance with live WebSocket state.",
|
||||||
|
)
|
||||||
|
async def list_terminal_sessions(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List terminal sessions for an instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with sessions list.
|
||||||
|
"""
|
||||||
|
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
|
||||||
|
# Query active DB rows for this instance
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(TerminalSessionModel)
|
||||||
|
.where(TerminalSessionModel.instance_id == instance_id)
|
||||||
|
.where(TerminalSessionModel.status != "closed")
|
||||||
|
.order_by(TerminalSessionModel.created_at.asc())
|
||||||
|
)
|
||||||
|
db_rows = result.scalars().all()
|
||||||
|
|
||||||
|
# Build response with live has_websockets flag.
|
||||||
|
# Include DB rows even without in-memory counterparts (e.g. after
|
||||||
|
# server restart) so the frontend can display tabs and reconnect.
|
||||||
|
sessions = []
|
||||||
|
for row in db_rows:
|
||||||
|
live_session = terminal_manager.get_session(str(instance_id), str(row.id))
|
||||||
|
sessions.append(
|
||||||
|
{
|
||||||
|
"id": str(row.id),
|
||||||
|
"name": row.name,
|
||||||
|
"status": row.status,
|
||||||
|
"has_websockets": live_session.has_websockets()
|
||||||
|
if live_session
|
||||||
|
else False,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"last_activity_at": row.last_activity_at.isoformat()
|
||||||
|
if row.last_activity_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"sessions": sessions}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/terminal/sessions",
|
||||||
|
summary="Create terminal session",
|
||||||
|
description="Create a new terminal session for a running tool instance.",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new terminal session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
data: Request body with optional name.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with new session details.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 409 if max sessions reached.
|
||||||
|
"""
|
||||||
|
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
assert instance.container_id is not None
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
|
||||||
|
name = data.get("name")
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = await terminal_manager.create_session(
|
||||||
|
instance_id,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
except MaxSessionsExceededError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Maximum of 5 terminal sessions reached for this instance",
|
||||||
|
) from None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": session.session_id,
|
||||||
|
"name": session.name,
|
||||||
|
"status": session.status,
|
||||||
|
"created_at": session.last_activity,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/instances/{instance_id}/terminal/sessions/{session_id}",
|
||||||
|
summary="Close terminal session",
|
||||||
|
description="Close a specific terminal session.",
|
||||||
|
)
|
||||||
|
async def close_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
session_id: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Close a terminal session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
session_id: ID of the session to close.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with closure status.
|
||||||
|
"""
|
||||||
|
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
|
||||||
|
# Find the session by internal ID to determine its slot key
|
||||||
|
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
|
||||||
|
if (
|
||||||
|
key is None
|
||||||
|
and terminal_manager.get_session(str(instance_id), session_id) is not None
|
||||||
|
):
|
||||||
|
key = (str(instance_id), session_id)
|
||||||
|
|
||||||
|
if key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
await terminal_manager.close_session(key[0], key[1])
|
||||||
|
|
||||||
|
return {"status": "closed", "session_id": session_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/terminal/sessions/{session_id}/reset",
|
||||||
|
summary="Reset terminal session",
|
||||||
|
description="Reset a specific terminal session, killing the current shell and starting fresh.",
|
||||||
|
)
|
||||||
|
async def reset_specific_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
session_id: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Reset a specific terminal session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
session_id: ID of the session to reset.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with reset session details.
|
||||||
|
"""
|
||||||
|
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
assert instance.container_id is not None
|
||||||
|
|
||||||
|
# Determine slot key for reset
|
||||||
|
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
|
||||||
|
if (
|
||||||
|
key is None
|
||||||
|
and terminal_manager.get_session(str(instance_id), session_id) is not None
|
||||||
|
):
|
||||||
|
key = (str(instance_id), session_id)
|
||||||
|
|
||||||
|
if key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
|
||||||
|
# Preserve name if possible
|
||||||
|
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||||
|
name = live_session.name if live_session else None
|
||||||
|
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
instance_id,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
session_id=key[1],
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": new_session.session_id,
|
||||||
|
"name": new_session.name,
|
||||||
|
"status": new_session.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/terminal/sessions/{session_id}/rename",
|
||||||
|
summary="Rename terminal session",
|
||||||
|
description="Rename a specific terminal session.",
|
||||||
|
)
|
||||||
|
async def rename_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
session_id: str,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Rename a terminal session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
session_id: ID of the session to rename.
|
||||||
|
data: Request body with new name.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with updated session details.
|
||||||
|
"""
|
||||||
|
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
|
||||||
|
new_name = data.get("name")
|
||||||
|
if not new_name or not isinstance(new_name, str):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update in-memory session name if live
|
||||||
|
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||||
|
if live_session:
|
||||||
|
live_session.name = new_name
|
||||||
|
|
||||||
|
# Update DB row
|
||||||
|
db_row = await db_session.get(TerminalSessionModel, uuid.UUID(session_id))
|
||||||
|
if db_row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
db_row.name = new_name
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
return {"id": str(db_row.id), "name": new_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/terminal/reset",
|
||||||
|
summary="Reset terminal session (legacy alias)",
|
||||||
|
description="Reset the default terminal session for a tool instance. Preserved for backward compatibility.",
|
||||||
|
)
|
||||||
|
async def reset_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Reset the default terminal session for an instance (legacy alias).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with status message.
|
||||||
|
"""
|
||||||
|
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
assert instance.container_id is not None
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Reset the default session
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
instance_id,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Terminal session reset for instance %s (new session_id=%s)",
|
||||||
|
instance_id,
|
||||||
|
new_session.session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Terminal session reset successfully",
|
||||||
|
"instance_id": str(instance_id),
|
||||||
|
"session_id": new_session.session_id,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to reset terminal session for instance %s: %s",
|
||||||
|
instance_id,
|
||||||
|
str(exc),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to reset terminal session: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user_from_websocket(
|
||||||
|
websocket: WebSocket,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> uuid.UUID | None:
|
||||||
|
"""Extract and validate user ID from session cookie in WebSocket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
websocket: The WebSocket connection.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The user's UUID if authenticated, None otherwise.
|
||||||
|
"""
|
||||||
|
from src.auth.session import decode_session_cookie
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
session_cookie = websocket.cookies.get("session")
|
||||||
|
if not session_cookie:
|
||||||
|
return None
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
try:
|
||||||
|
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
|
return uuid.UUID(str(payload["user_id"]))
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
return None
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
"""Tool definition API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, 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.tool_definition_manifest import ToolDefinitionManifest
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.services.manifest_compiler import (
|
||||||
|
compile_compose,
|
||||||
|
compile_dockerfile,
|
||||||
|
compile_entrypoint,
|
||||||
|
compute_image_tag,
|
||||||
|
deep_merge,
|
||||||
|
resolve_base,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tool-definitions", tags=["tool-definitions"])
|
||||||
|
|
||||||
|
|
||||||
|
class CreateToolDefinitionRequest(BaseModel):
|
||||||
|
"""Request body for creating a tool definition manifest."""
|
||||||
|
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
name: str = Field(description="Unique identifier (kebab-case)")
|
||||||
|
display_name: str = Field(description="Human-readable name")
|
||||||
|
description: str | None = Field(default=None)
|
||||||
|
category: str = Field(default="development")
|
||||||
|
interface_type: str = Field(default="terminal", description="web or terminal")
|
||||||
|
base_image: str | None = Field(default=None, description="Direct base image")
|
||||||
|
base_definition_id: str | None = Field(
|
||||||
|
default=None, description="Reference to a base definition"
|
||||||
|
)
|
||||||
|
base_version: str = Field(default="latest")
|
||||||
|
manifest: dict = Field(description="The full manifest JSON")
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateToolDefinitionRequest(BaseModel):
|
||||||
|
"""Request body for updating a tool definition manifest."""
|
||||||
|
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
display_name: str | None = Field(default=None)
|
||||||
|
description: str | None = Field(default=None)
|
||||||
|
category: str | None = Field(default=None)
|
||||||
|
manifest: dict | None = Field(default=None)
|
||||||
|
base_version: str | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
summary="Create tool definition",
|
||||||
|
description="Create a new tool definition manifest.",
|
||||||
|
)
|
||||||
|
async def create_tool_definition(
|
||||||
|
data: CreateToolDefinitionRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new tool definition manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Manifest data.
|
||||||
|
user_id: Authenticated user ID.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with created definition details.
|
||||||
|
"""
|
||||||
|
# Validate base reference
|
||||||
|
if not data.base_image and not data.base_definition_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Either base_image or base_definition_id is required",
|
||||||
|
)
|
||||||
|
|
||||||
|
base_def_id = None
|
||||||
|
if data.base_definition_id:
|
||||||
|
try:
|
||||||
|
base_def_id = uuid.UUID(data.base_definition_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid base_definition_id: {data.base_definition_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
base_def = await session.get(ToolDefinitionManifest, base_def_id)
|
||||||
|
if not base_def:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Base definition not found: {data.base_definition_id}",
|
||||||
|
)
|
||||||
|
if not base_def.is_base:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Referenced definition is not a base definition",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check name uniqueness
|
||||||
|
existing = await session.execute(
|
||||||
|
select(ToolDefinitionManifest).where(ToolDefinitionManifest.name == data.name)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Tool definition '{data.name}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
definition = ToolDefinitionManifest(
|
||||||
|
name=data.name,
|
||||||
|
display_name=data.display_name,
|
||||||
|
description=data.description,
|
||||||
|
category=data.category,
|
||||||
|
interface_type=data.interface_type,
|
||||||
|
base_image=data.base_image,
|
||||||
|
base_definition_id=base_def_id,
|
||||||
|
base_version=data.base_version,
|
||||||
|
manifest=data.manifest,
|
||||||
|
created_by_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(definition)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(definition)
|
||||||
|
|
||||||
|
logger.info("Created tool definition %s (%s)", definition.id, definition.name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(definition.id),
|
||||||
|
"name": definition.name,
|
||||||
|
"display_name": definition.display_name,
|
||||||
|
"description": definition.description,
|
||||||
|
"category": definition.category,
|
||||||
|
"interface_type": definition.interface_type,
|
||||||
|
"base_image": definition.base_image,
|
||||||
|
"base_definition_id": str(definition.base_definition_id)
|
||||||
|
if definition.base_definition_id
|
||||||
|
else None,
|
||||||
|
"base_version": definition.base_version,
|
||||||
|
"manifest": definition.manifest,
|
||||||
|
"is_base": definition.is_base,
|
||||||
|
"created_at": definition.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
summary="List tool definitions",
|
||||||
|
description="List all tool definition manifests.",
|
||||||
|
)
|
||||||
|
async def list_tool_definitions(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
include_bases: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""List all tool definition manifests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Authenticated user ID.
|
||||||
|
session: Database session.
|
||||||
|
include_bases: Whether to include base definitions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing list of definitions.
|
||||||
|
"""
|
||||||
|
query = select(ToolDefinitionManifest)
|
||||||
|
if not include_bases:
|
||||||
|
query = query.where(ToolDefinitionManifest.is_base == False)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
query.order_by(ToolDefinitionManifest.created_at.desc())
|
||||||
|
)
|
||||||
|
definitions = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"definitions": [
|
||||||
|
{
|
||||||
|
"id": str(d.id),
|
||||||
|
"name": d.name,
|
||||||
|
"display_name": d.display_name,
|
||||||
|
"description": d.description,
|
||||||
|
"category": d.category,
|
||||||
|
"interface_type": d.interface_type,
|
||||||
|
"is_base": d.is_base,
|
||||||
|
"base_image": d.base_image,
|
||||||
|
"base_definition_id": str(d.base_definition_id)
|
||||||
|
if d.base_definition_id
|
||||||
|
else None,
|
||||||
|
"base_version": d.base_version,
|
||||||
|
"version": d.version,
|
||||||
|
"created_at": d.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for d in definitions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{definition_id}",
|
||||||
|
summary="Get tool definition",
|
||||||
|
description="Get a specific tool definition manifest.",
|
||||||
|
)
|
||||||
|
async def get_tool_definition(
|
||||||
|
definition_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get a specific tool definition manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
definition_id: UUID of the definition.
|
||||||
|
user_id: Authenticated user ID.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with definition details.
|
||||||
|
"""
|
||||||
|
definition = await session.get(ToolDefinitionManifest, definition_id)
|
||||||
|
if not definition:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tool definition not found: {definition_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(definition.id),
|
||||||
|
"name": definition.name,
|
||||||
|
"display_name": definition.display_name,
|
||||||
|
"description": definition.description,
|
||||||
|
"category": definition.category,
|
||||||
|
"interface_type": definition.interface_type,
|
||||||
|
"base_image": definition.base_image,
|
||||||
|
"base_definition_id": str(definition.base_definition_id)
|
||||||
|
if definition.base_definition_id
|
||||||
|
else None,
|
||||||
|
"base_version": definition.base_version,
|
||||||
|
"manifest": definition.manifest,
|
||||||
|
"dockerfile_cache": definition.dockerfile_cache,
|
||||||
|
"compose_cache": definition.compose_cache,
|
||||||
|
"version": definition.version,
|
||||||
|
"is_base": definition.is_base,
|
||||||
|
"created_at": definition.created_at.isoformat(),
|
||||||
|
"updated_at": definition.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{definition_id}",
|
||||||
|
summary="Update tool definition",
|
||||||
|
description="Update a tool definition manifest.",
|
||||||
|
)
|
||||||
|
async def update_tool_definition(
|
||||||
|
definition_id: uuid.UUID,
|
||||||
|
data: UpdateToolDefinitionRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update a tool definition manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
definition_id: UUID of the definition.
|
||||||
|
data: Update data.
|
||||||
|
user_id: Authenticated user ID.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with updated definition details.
|
||||||
|
"""
|
||||||
|
definition = await session.get(ToolDefinitionManifest, definition_id)
|
||||||
|
if not definition:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tool definition not found: {definition_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.display_name is not None:
|
||||||
|
definition.display_name = data.display_name
|
||||||
|
if data.description is not None:
|
||||||
|
definition.description = data.description
|
||||||
|
if data.category is not None:
|
||||||
|
definition.category = data.category
|
||||||
|
if data.manifest is not None:
|
||||||
|
definition.manifest = data.manifest
|
||||||
|
if data.base_version is not None:
|
||||||
|
definition.base_version = data.base_version
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(definition)
|
||||||
|
|
||||||
|
logger.info("Updated tool definition %s (%s)", definition.id, definition.name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(definition.id),
|
||||||
|
"name": definition.name,
|
||||||
|
"display_name": definition.display_name,
|
||||||
|
"manifest": definition.manifest,
|
||||||
|
"updated_at": definition.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{definition_id}",
|
||||||
|
summary="Delete tool definition",
|
||||||
|
description="Delete a tool definition manifest.",
|
||||||
|
)
|
||||||
|
async def delete_tool_definition(
|
||||||
|
definition_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Delete a tool definition manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
definition_id: UUID of the definition.
|
||||||
|
user_id: Authenticated user ID.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with deletion status.
|
||||||
|
"""
|
||||||
|
definition = await session.get(ToolDefinitionManifest, definition_id)
|
||||||
|
if not definition:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tool definition not found: {definition_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if any tool types reference this manifest
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolType).where(ToolType.manifest_id == definition_id)
|
||||||
|
)
|
||||||
|
referencing = result.scalars().all()
|
||||||
|
if referencing:
|
||||||
|
tool_names = ", ".join(t.name for t in referencing)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Cannot delete: referenced by tool types: {tool_names}",
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.delete(definition)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info("Deleted tool definition %s (%s)", definition.id, definition.name)
|
||||||
|
|
||||||
|
return {"status": "deleted", "id": str(definition_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{definition_id}/compile",
|
||||||
|
summary="Compile tool definition",
|
||||||
|
description="Compile a manifest to Dockerfile + Compose preview without building.",
|
||||||
|
)
|
||||||
|
async def compile_tool_definition(
|
||||||
|
definition_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Compile a manifest to Dockerfile + Compose preview.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
definition_id: UUID of the definition.
|
||||||
|
user_id: Authenticated user ID.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with compiled Dockerfile, Compose, and image tag.
|
||||||
|
"""
|
||||||
|
definition = await session.get(ToolDefinitionManifest, definition_id)
|
||||||
|
if not definition:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tool definition not found: {definition_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = dict(definition.manifest)
|
||||||
|
|
||||||
|
# Resolve base if referenced
|
||||||
|
if definition.base_definition_id:
|
||||||
|
base_def = await session.get(
|
||||||
|
ToolDefinitionManifest, definition.base_definition_id
|
||||||
|
)
|
||||||
|
if base_def:
|
||||||
|
base_manifest = dict(base_def.manifest)
|
||||||
|
manifest = resolve_base(deep_merge(base_manifest, manifest))
|
||||||
|
|
||||||
|
# Compile
|
||||||
|
dockerfile = compile_dockerfile(manifest)
|
||||||
|
entrypoint = compile_entrypoint(manifest)
|
||||||
|
image_tag = compute_image_tag(definition.name, manifest)
|
||||||
|
|
||||||
|
# Dummy compose with placeholder variables
|
||||||
|
dummy_vars = {
|
||||||
|
"IMAGE_TAG": image_tag,
|
||||||
|
"INSTANCE_NAME": f"{definition.name}-preview",
|
||||||
|
"INSTANCE_DIR": "/data/instances/preview",
|
||||||
|
"REPO_PATH": "/data/repos/preview",
|
||||||
|
"SSH_PATH": "/data/instances/preview/.ssh",
|
||||||
|
"TOOL_PORT": "8080",
|
||||||
|
"EXTRA_ENV": {},
|
||||||
|
"EXTRA_VOLUMES": [],
|
||||||
|
}
|
||||||
|
compose = compile_compose(manifest, dummy_vars)
|
||||||
|
|
||||||
|
# Update cache
|
||||||
|
definition.dockerfile_cache = dockerfile
|
||||||
|
definition.compose_cache = compose
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(definition.id),
|
||||||
|
"name": definition.name,
|
||||||
|
"dockerfile": dockerfile,
|
||||||
|
"entrypoint": entrypoint,
|
||||||
|
"compose": compose,
|
||||||
|
"image_tag": image_tag,
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+444
-73
@@ -1,27 +1,29 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import yaml
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.api.tool_types_validation import (
|
||||||
|
check_port_exposed,
|
||||||
|
validate_compose_yaml,
|
||||||
|
validate_required_variables,
|
||||||
|
)
|
||||||
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|
||||||
user = await session.get(User, user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_admin(user: User) -> None:
|
async def _require_admin(user: User) -> None:
|
||||||
|
"""Check if user has admin privileges.
|
||||||
|
|
||||||
|
For now, all authenticated users can manage tool types.
|
||||||
|
In production, this should check user.role or similar.
|
||||||
|
"""
|
||||||
# For now, all authenticated users can manage tool types
|
# For now, all authenticated users can manage tool types
|
||||||
# In production, check user.role or similar
|
# In production, check user.role or similar
|
||||||
pass
|
pass
|
||||||
@@ -31,26 +33,76 @@ class ToolTypeCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
compose_template: str
|
default_port: int = 0
|
||||||
|
definition_type: str = "compose"
|
||||||
|
manifest_id: uuid.UUID | None = None
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
build_context: dict | None = None
|
||||||
|
readiness_probe: dict | None = None
|
||||||
|
startup_command: str | None = None
|
||||||
required_variables: list[str] = []
|
required_variables: list[str] = []
|
||||||
|
category: str = "other"
|
||||||
|
interface_type: str = "web"
|
||||||
|
requires_port: bool = True
|
||||||
|
|
||||||
|
@field_validator("definition_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_definition_type(cls, v: str) -> str:
|
||||||
|
if v not in ("compose", "dockerfile", "manifest"):
|
||||||
|
raise ValueError(
|
||||||
|
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("compose_template")
|
@field_validator("compose_template")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_compose_template(cls, v: str) -> str:
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
try:
|
data = info.data
|
||||||
parsed = yaml.safe_load(v)
|
if data.get("definition_type") != "compose":
|
||||||
except yaml.YAMLError as e:
|
return v
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
if not isinstance(parsed, dict):
|
if v is None or not v.strip():
|
||||||
raise ValueError("Compose template must be a YAML mapping")
|
raise ValueError(
|
||||||
|
"compose_template is required when definition_type is 'compose'"
|
||||||
|
)
|
||||||
|
|
||||||
if "services" not in parsed:
|
validate_compose_yaml(v)
|
||||||
raise ValueError("Compose template must contain 'services' key")
|
return v
|
||||||
|
|
||||||
if not parsed["services"]:
|
@field_validator("dockerfile_template")
|
||||||
raise ValueError("Compose template must define at least one service")
|
@classmethod
|
||||||
|
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "dockerfile":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if v is None or not v.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"dockerfile_template is required when definition_type is 'dockerfile'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not v.strip().startswith("FROM"):
|
||||||
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("interface_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_interface_type(cls, v: str) -> str:
|
||||||
|
if v not in ("web", "terminal"):
|
||||||
|
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("default_port")
|
||||||
|
@classmethod
|
||||||
|
def validate_default_port(cls, v: int, info) -> int:
|
||||||
|
data = info.data
|
||||||
|
requires_port = data.get("requires_port", True)
|
||||||
|
if not requires_port:
|
||||||
|
return v
|
||||||
|
if v <= 0 or v > 65535:
|
||||||
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("required_variables")
|
@field_validator("required_variables")
|
||||||
@@ -59,45 +111,127 @@ class ToolTypeCreate(BaseModel):
|
|||||||
if not v:
|
if not v:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
# Get compose_template from the model data
|
|
||||||
data = info.data
|
data = info.data
|
||||||
if "compose_template" not in data:
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
|
template = data.get("compose_template")
|
||||||
|
if not template:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
template = data["compose_template"]
|
|
||||||
for var in v:
|
for var in v:
|
||||||
placeholder = f"{{{{{var}}}}}"
|
placeholder = f"{{{{{var}}}}}"
|
||||||
if placeholder not in template:
|
if placeholder not in template:
|
||||||
raise ValueError(f"Required variable '{var}' not found in compose template")
|
raise ValueError(
|
||||||
|
f"Required variable '{var}' not found in compose template"
|
||||||
|
)
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_templates(self) -> "ToolTypeCreate":
|
||||||
|
if self.definition_type == "manifest":
|
||||||
|
if self.manifest_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"manifest_id is required when definition_type is 'manifest'"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
if self.definition_type == "dockerfile" and (
|
||||||
|
self.dockerfile_template is None or not self.dockerfile_template.strip()
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"dockerfile_template is required when definition_type is 'dockerfile'"
|
||||||
|
)
|
||||||
|
if self.definition_type == "compose" and (
|
||||||
|
self.compose_template is None or not self.compose_template.strip()
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"compose_template is required when definition_type is 'compose'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate that default_port is exposed in compose template (only if requires_port)
|
||||||
|
if (
|
||||||
|
self.requires_port
|
||||||
|
and self.definition_type == "compose"
|
||||||
|
and self.compose_template
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
parsed = validate_compose_yaml(self.compose_template)
|
||||||
|
except ValueError:
|
||||||
|
return self
|
||||||
|
|
||||||
|
if not check_port_exposed(parsed, self.default_port):
|
||||||
|
raise ValueError(
|
||||||
|
f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section."
|
||||||
|
)
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeUpdate(BaseModel):
|
class ToolTypeUpdate(BaseModel):
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
default_port: int | None = None
|
||||||
|
definition_type: str | None = None
|
||||||
|
manifest_id: uuid.UUID | None = None
|
||||||
compose_template: str | None = None
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
build_context: dict | None = None
|
||||||
|
readiness_probe: dict | None = None
|
||||||
|
startup_command: str | None = None
|
||||||
required_variables: list[str] | None = None
|
required_variables: list[str] | None = None
|
||||||
|
category: str | None = None
|
||||||
|
interface_type: str | None = None
|
||||||
|
requires_port: bool | None = None
|
||||||
|
|
||||||
|
@field_validator("definition_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_definition_type(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v not in ("compose", "dockerfile", "manifest"):
|
||||||
|
raise ValueError(
|
||||||
|
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("interface_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_interface_type(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v not in ("web", "terminal"):
|
||||||
|
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("compose_template")
|
@field_validator("compose_template")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_compose_template(cls, v: str | None) -> str | None:
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
if v is None:
|
if v is None:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
try:
|
data = info.data
|
||||||
parsed = yaml.safe_load(v)
|
definition_type = data.get("definition_type")
|
||||||
except yaml.YAMLError as e:
|
if definition_type and definition_type != "compose":
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
return v
|
||||||
|
|
||||||
if not isinstance(parsed, dict):
|
validate_compose_yaml(v)
|
||||||
raise ValueError("Compose template must be a YAML mapping")
|
return v
|
||||||
|
|
||||||
if "services" not in parsed:
|
@field_validator("dockerfile_template")
|
||||||
raise ValueError("Compose template must contain 'services' key")
|
@classmethod
|
||||||
|
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
if not parsed["services"]:
|
data = info.data
|
||||||
raise ValueError("Compose template must define at least one service")
|
definition_type = data.get("definition_type")
|
||||||
|
if definition_type and definition_type != "dockerfile":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if not v.strip().startswith("FROM"):
|
||||||
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -109,35 +243,72 @@ class ToolTypeResponse(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
description: str | None
|
description: str | None
|
||||||
compose_template: str
|
category: str
|
||||||
|
interface_type: str
|
||||||
|
requires_port: bool
|
||||||
|
default_port: int
|
||||||
|
definition_type: str
|
||||||
|
manifest_id: uuid.UUID | None
|
||||||
|
compose_template: str | None
|
||||||
|
dockerfile_template: str | None
|
||||||
|
build_context: dict | None
|
||||||
|
readiness_probe: dict | None
|
||||||
|
startup_command: str | None
|
||||||
required_variables: list[str]
|
required_variables: list[str]
|
||||||
is_builtin: bool
|
|
||||||
created_by_id: uuid.UUID | None
|
created_by_id: uuid.UUID | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ToolTypeResponse, status_code=status.HTTP_201_CREATED)
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=ToolTypeResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create tool type",
|
||||||
|
description="Create a new custom tool type with a Docker Compose template.",
|
||||||
|
)
|
||||||
async def create_tool_type(
|
async def create_tool_type(
|
||||||
data: ToolTypeCreate,
|
data: ToolTypeCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
|
"""Create a new tool type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Tool type creation data including name, display name, and compose template.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created tool type.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
# Check for duplicate name
|
# Check for duplicate name
|
||||||
existing = await session.scalar(select(ToolType).where(ToolType.name == data.name))
|
existing = await session.scalar(select(ToolType).where(ToolType.name == data.name))
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="tool type with this name already exists")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="tool type with this name already exists",
|
||||||
|
)
|
||||||
|
|
||||||
tool_type = ToolType(
|
tool_type = ToolType(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
display_name=data.display_name,
|
display_name=data.display_name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
|
default_port=data.default_port,
|
||||||
|
definition_type=data.definition_type,
|
||||||
|
manifest_id=data.manifest_id,
|
||||||
compose_template=data.compose_template,
|
compose_template=data.compose_template,
|
||||||
|
dockerfile_template=data.dockerfile_template,
|
||||||
|
build_context=data.build_context,
|
||||||
|
readiness_probe=data.readiness_probe,
|
||||||
|
startup_command=data.startup_command,
|
||||||
required_variables=data.required_variables,
|
required_variables=data.required_variables,
|
||||||
is_builtin=False,
|
category=data.category,
|
||||||
|
interface_type=data.interface_type,
|
||||||
|
requires_port=data.requires_port,
|
||||||
created_by_id=user.id,
|
created_by_id=user.id,
|
||||||
)
|
)
|
||||||
session.add(tool_type)
|
session.add(tool_type)
|
||||||
@@ -146,68 +317,141 @@ async def create_tool_type(
|
|||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ToolTypeResponse])
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=list[ToolTypeResponse],
|
||||||
|
summary="List tool types",
|
||||||
|
description="List all available tool types including built-in and custom ones.",
|
||||||
|
)
|
||||||
async def list_tool_types(
|
async def list_tool_types(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[ToolType]:
|
) -> list[ToolType]:
|
||||||
|
"""List all tool types.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of all tool types ordered by name.
|
||||||
|
"""
|
||||||
await _get_user(session, user_id)
|
await _get_user(session, user_id)
|
||||||
result = await session.execute(select(ToolType).order_by(ToolType.name))
|
result = await session.execute(select(ToolType).order_by(ToolType.name))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tool_type_id}", response_model=ToolTypeResponse)
|
@router.get(
|
||||||
|
"/{tool_type_id}",
|
||||||
|
response_model=ToolTypeResponse,
|
||||||
|
summary="Get tool type",
|
||||||
|
description="Get a specific tool type by ID.",
|
||||||
|
)
|
||||||
async def get_tool_type(
|
async def get_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
|
"""Get a specific tool type by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_type_id: UUID of the tool type to retrieve.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The requested tool type.
|
||||||
|
"""
|
||||||
await _get_user(session, user_id)
|
await _get_user(session, user_id)
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
|
)
|
||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{tool_type_id}", response_model=ToolTypeResponse)
|
@router.put(
|
||||||
|
"/{tool_type_id}",
|
||||||
|
response_model=ToolTypeResponse,
|
||||||
|
summary="Update tool type",
|
||||||
|
description="Update a custom tool type. Built-in tool types cannot be modified.",
|
||||||
|
)
|
||||||
async def update_tool_type(
|
async def update_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
data: ToolTypeUpdate,
|
data: ToolTypeUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
|
"""Update a tool type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_type_id: UUID of the tool type to update.
|
||||||
|
data: Tool type update data with optional fields.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated tool type.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
|
)
|
||||||
|
|
||||||
if tool_type.is_builtin:
|
# Built-in tool types can now be modified
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types")
|
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Validate required variables if both are being updated
|
# Validate port if being updated
|
||||||
if "required_variables" in update_data and "compose_template" in update_data:
|
requires_port = update_data.get("requires_port", tool_type.requires_port)
|
||||||
template = update_data["compose_template"]
|
if "default_port" in update_data and requires_port:
|
||||||
for var in update_data["required_variables"]:
|
new_port = update_data["default_port"]
|
||||||
placeholder = f"{{{{{var}}}}}"
|
if new_port <= 0 or new_port > 65535:
|
||||||
if placeholder not in template:
|
raise HTTPException(
|
||||||
raise HTTPException(
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
detail="Port must be between 1 and 65535",
|
||||||
detail=f"Required variable '{var}' not found in compose template"
|
)
|
||||||
)
|
|
||||||
elif "required_variables" in update_data:
|
# Only validate port exposure for compose definitions
|
||||||
# Only updating variables, check against existing template
|
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||||
template = tool_type.compose_template
|
if definition_type == "compose":
|
||||||
for var in update_data["required_variables"]:
|
template = update_data.get("compose_template", tool_type.compose_template)
|
||||||
placeholder = f"{{{{{var}}}}}"
|
if template:
|
||||||
if placeholder not in template:
|
try:
|
||||||
raise HTTPException(
|
parsed = validate_compose_yaml(template)
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
if not check_port_exposed(parsed, new_port):
|
||||||
detail=f"Required variable '{var}' not found in compose template"
|
raise HTTPException(
|
||||||
)
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Port {new_port} is not exposed in the compose template",
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate required variables for compose definitions
|
||||||
|
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||||
|
if definition_type == "compose":
|
||||||
|
if "required_variables" in update_data and "compose_template" in update_data:
|
||||||
|
validate_required_variables(
|
||||||
|
update_data["compose_template"], update_data["required_variables"]
|
||||||
|
)
|
||||||
|
elif "required_variables" in update_data:
|
||||||
|
template = tool_type.compose_template
|
||||||
|
if template:
|
||||||
|
validate_required_variables(template, update_data["required_variables"])
|
||||||
|
|
||||||
|
# When switching to manifest, clear legacy templates
|
||||||
|
if definition_type == "manifest":
|
||||||
|
if "manifest_id" in update_data:
|
||||||
|
tool_type.manifest_id = update_data["manifest_id"]
|
||||||
|
tool_type.compose_template = None
|
||||||
|
tool_type.dockerfile_template = None
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(tool_type, field, value)
|
setattr(tool_type, field, value)
|
||||||
@@ -217,21 +461,148 @@ async def update_tool_type(
|
|||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tool_type_id}", status_code=status.HTTP_204_NO_CONTENT)
|
class ToolTypeValidateRequest(BaseModel):
|
||||||
|
definition_type: str
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/validate",
|
||||||
|
summary="Validate tool type template",
|
||||||
|
description="Validate a compose template or dockerfile syntax before creating a tool type.",
|
||||||
|
)
|
||||||
|
async def validate_tool_type_template(
|
||||||
|
data: ToolTypeValidateRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Validate a tool type template syntax.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Validation request with definition type and template.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validation result with success status and any errors.
|
||||||
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if data.definition_type == "compose":
|
||||||
|
if not data.compose_template:
|
||||||
|
errors.append("Compose template is required")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
validate_compose_yaml(data.compose_template)
|
||||||
|
except ValueError as e:
|
||||||
|
errors.append(str(e))
|
||||||
|
|
||||||
|
elif data.definition_type == "dockerfile":
|
||||||
|
if not data.dockerfile_template:
|
||||||
|
errors.append("Dockerfile template is required")
|
||||||
|
elif not data.dockerfile_template.strip().startswith("FROM"):
|
||||||
|
errors.append("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
elif data.definition_type == "manifest":
|
||||||
|
pass # Manifest validation is handled separately
|
||||||
|
|
||||||
|
else:
|
||||||
|
errors.append("definition_type must be 'compose', 'dockerfile', or 'manifest'")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": len(errors) == 0,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{tool_type_id}/validate",
|
||||||
|
summary="Validate tool type",
|
||||||
|
description="Validate the compose template or dockerfile syntax of a tool type.",
|
||||||
|
)
|
||||||
|
async def validate_tool_type(
|
||||||
|
tool_type_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Validate a tool type's template syntax.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_type_id: UUID of the tool type to validate.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validation result with success status and any errors.
|
||||||
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
|
if tool_type is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if tool_type.definition_type == "compose":
|
||||||
|
if not tool_type.compose_template:
|
||||||
|
errors.append("Compose template is empty")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
validate_compose_yaml(tool_type.compose_template)
|
||||||
|
except ValueError as e:
|
||||||
|
errors.append(str(e))
|
||||||
|
|
||||||
|
elif tool_type.definition_type == "dockerfile":
|
||||||
|
if not tool_type.dockerfile_template:
|
||||||
|
errors.append("Dockerfile template is empty")
|
||||||
|
elif not tool_type.dockerfile_template.strip().startswith("FROM"):
|
||||||
|
errors.append("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
elif tool_type.definition_type == "manifest":
|
||||||
|
if not tool_type.manifest_id:
|
||||||
|
errors.append("Manifest reference is missing")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": len(errors) == 0,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{tool_type_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete tool type",
|
||||||
|
description="Delete a custom tool type. Built-in tool types cannot be deleted.",
|
||||||
|
)
|
||||||
async def delete_tool_type(
|
async def delete_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Delete a tool type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_type_id: UUID of the tool type to delete.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None with 204 status code.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
|
)
|
||||||
|
|
||||||
if tool_type.is_builtin:
|
# Built-in tool types can now be deleted
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types")
|
|
||||||
|
|
||||||
await session.delete(tool_type)
|
await session.delete(tool_type)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Shared validation utilities for tool types."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_template_vars(template: str) -> str:
|
||||||
|
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
|
||||||
|
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_compose_yaml(template: str) -> dict:
|
||||||
|
"""Validate and parse a compose template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Raw compose template string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed YAML dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If YAML is invalid or missing required keys.
|
||||||
|
"""
|
||||||
|
sanitized = sanitize_template_vars(template)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(sanitized)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise ValueError(f"Invalid YAML: {e}")
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("Compose template must be a YAML mapping")
|
||||||
|
|
||||||
|
if "services" not in parsed:
|
||||||
|
raise ValueError("Compose template must contain 'services' key")
|
||||||
|
|
||||||
|
if not parsed["services"]:
|
||||||
|
raise ValueError("Compose template must define at least one service")
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def check_port_exposed(parsed: dict, port: int) -> bool:
|
||||||
|
"""Check if a port is exposed in a parsed compose template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parsed: Parsed compose YAML dict.
|
||||||
|
port: Port number to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if port is exposed in any service.
|
||||||
|
"""
|
||||||
|
port_str = str(port)
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict) or "services" not in parsed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for service_config in parsed["services"].values():
|
||||||
|
if isinstance(service_config, dict) and "ports" in service_config:
|
||||||
|
for port_mapping in service_config["ports"]:
|
||||||
|
if isinstance(port_mapping, str) and port_str in port_mapping:
|
||||||
|
return True
|
||||||
|
elif isinstance(port_mapping, int) and port_mapping == port:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_required_variables(template: str, variables: list[str]) -> None:
|
||||||
|
"""Validate that all required variables exist in the template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Compose template string.
|
||||||
|
variables: List of required variable names.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If any variable is not found in the template.
|
||||||
|
"""
|
||||||
|
for var in variables:
|
||||||
|
placeholder = f"{{{{{var}}}}}"
|
||||||
|
if placeholder not in template:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Required variable '{var}' not found in compose template",
|
||||||
|
)
|
||||||
@@ -1,26 +1,34 @@
|
|||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.user import User
|
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_or_create_config(
|
||||||
user = await session.get(User, user_id)
|
session: AsyncSession, user_id: uuid.UUID
|
||||||
if user is None:
|
) -> UserConfig:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
"""Get or create user config record.
|
||||||
return user
|
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
user_id: UUID of the user.
|
||||||
|
|
||||||
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
Returns:
|
||||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
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)
|
||||||
|
)
|
||||||
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={})
|
||||||
@@ -37,6 +45,9 @@ class UserConfigResponse(BaseModel):
|
|||||||
theme: str = "system"
|
theme: str = "system"
|
||||||
git_user_name: str | None = None
|
git_user_name: str | None = None
|
||||||
git_user_email: str | None = None
|
git_user_email: str | None = None
|
||||||
|
last_session_id: str | None = None
|
||||||
|
notification_mute_categories: list[str] | None = None
|
||||||
|
notification_toast_level: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserConfigUpdate(BaseModel):
|
class UserConfigUpdate(BaseModel):
|
||||||
@@ -44,31 +55,66 @@ class UserConfigUpdate(BaseModel):
|
|||||||
theme: str | None = None
|
theme: str | None = None
|
||||||
git_user_name: str | None = None
|
git_user_name: str | None = None
|
||||||
git_user_email: str | None = None
|
git_user_email: str | None = None
|
||||||
|
last_session_id: str | None = None
|
||||||
|
notification_mute_categories: list[str] | None = None
|
||||||
|
notification_toast_level: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=UserConfigResponse)
|
@router.get(
|
||||||
|
"/config",
|
||||||
|
response_model=UserConfigResponse,
|
||||||
|
summary="Get user config",
|
||||||
|
description="Get the current user's configuration settings.",
|
||||||
|
)
|
||||||
async def get_user_config(
|
async def get_user_config(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
|
"""Get the current user's configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The user's configuration settings.
|
||||||
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
_user = await _get_user(session, user_id)
|
||||||
config = await _get_or_create_config(session, user_id)
|
config = await _get_or_create_config(session, user_id)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/config", response_model=UserConfigResponse)
|
@router.patch(
|
||||||
|
"/config",
|
||||||
|
response_model=UserConfigResponse,
|
||||||
|
summary="Update user config",
|
||||||
|
description="Update the current user's configuration settings.",
|
||||||
|
)
|
||||||
async def update_user_config(
|
async def update_user_config(
|
||||||
data: UserConfigUpdate,
|
data: UserConfigUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
|
"""Update the current user's configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Configuration update data with optional fields.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated user configuration.
|
||||||
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
_user = await _get_user(session, user_id)
|
||||||
config = await _get_or_create_config(session, user_id)
|
config = await _get_or_create_config(session, user_id)
|
||||||
|
|
||||||
# Merge updates
|
# Merge updates
|
||||||
update_data = data.model_dump(exclude_unset=True, exclude_none=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
config.config.update(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
|
||||||
|
config.config = {**config.config, **update_data}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
|
logger.debug("Updated config: %s", config.config)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
|
|||||||
+48
-11
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
@@ -16,13 +16,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
|
|||||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|
||||||
user = await session.get(User, user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfileResponse(BaseModel):
|
class UserProfileResponse(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -37,20 +30,49 @@ class UserProfileUpdate(BaseModel):
|
|||||||
email: str | None = None
|
email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserProfileResponse)
|
@router.get(
|
||||||
|
"/me",
|
||||||
|
response_model=UserProfileResponse,
|
||||||
|
summary="Get current user profile",
|
||||||
|
description="Retrieve the profile of the currently authenticated user.",
|
||||||
|
)
|
||||||
async def get_profile(
|
async def get_profile(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
|
"""Get the current user's profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The user's profile information.
|
||||||
|
"""
|
||||||
return await _get_user(session, user_id)
|
return await _get_user(session, user_id)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/me", response_model=UserProfileResponse)
|
@router.put(
|
||||||
|
"/me",
|
||||||
|
response_model=UserProfileResponse,
|
||||||
|
summary="Update user profile",
|
||||||
|
description="Update the current user's profile information.",
|
||||||
|
)
|
||||||
async def update_profile(
|
async def update_profile(
|
||||||
data: UserProfileUpdate,
|
data: UserProfileUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
|
"""Update the current user's profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Profile update data with optional name and email.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated user profile.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
@@ -68,12 +90,27 @@ async def update_profile(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.post("/me/avatar", response_model=UserProfileResponse)
|
@router.post(
|
||||||
|
"/me/avatar",
|
||||||
|
response_model=UserProfileResponse,
|
||||||
|
summary="Upload avatar",
|
||||||
|
description="Upload a profile avatar image (PNG or JPG, max 2MB).",
|
||||||
|
)
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
|
"""Upload a profile avatar image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: The image file to upload (PNG or JPG, max 2MB).
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated user profile with new avatar URL.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Workspace file API endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
from src.services.file_service import FileService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_workspace(
|
||||||
|
session: AsyncSession,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> Workspace:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Workspace).where(
|
||||||
|
Workspace.id == workspace_id,
|
||||||
|
Workspace.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workspace = result.scalar_one_or_none()
|
||||||
|
if not workspace:
|
||||||
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_files(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
path: str = "",
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List files in a workspace directory."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
service = FileService()
|
||||||
|
try:
|
||||||
|
entries = service.list_directory(workspace, path)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": e.name,
|
||||||
|
"path": e.path,
|
||||||
|
"type": e.type,
|
||||||
|
"size": e.size,
|
||||||
|
}
|
||||||
|
for e in entries
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/content")
|
||||||
|
async def get_file_content(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
path: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get the content of a text file."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
service = FileService()
|
||||||
|
try:
|
||||||
|
content = service.read_file(workspace, path)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"content": content, "path": path}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/content")
|
||||||
|
async def write_file(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Write a file and optionally commit."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
service = FileService()
|
||||||
|
|
||||||
|
file_path = data.get("path", "").strip()
|
||||||
|
content = data.get("content", "")
|
||||||
|
commit_message = data.get("message", "").strip()
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
raise HTTPException(status_code=400, detail="File path is required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
service.write_file(workspace, file_path, content)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
if commit_message:
|
||||||
|
from src.services.git_operations import GitOperations
|
||||||
|
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.commit(commit_message)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "saved", "path": file_path}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Workspace git API endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
from src.services.git_operations import GitOperations
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_workspace(
|
||||||
|
session: AsyncSession,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> Workspace:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Workspace).where(
|
||||||
|
Workspace.id == workspace_id,
|
||||||
|
Workspace.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workspace = result.scalar_one_or_none()
|
||||||
|
if not workspace:
|
||||||
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def git_status(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get git status for the workspace."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
status = await git.status()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"branch": status.branch,
|
||||||
|
"modified": status.modified,
|
||||||
|
"added": status.added,
|
||||||
|
"deleted": status.deleted,
|
||||||
|
"untracked": status.untracked,
|
||||||
|
"ahead": status.ahead,
|
||||||
|
"behind": status.behind,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/branches")
|
||||||
|
async def git_branches(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List branches for the workspace."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
branches, current = await git.branches()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"branches": branches,
|
||||||
|
"current_branch": current,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/commit")
|
||||||
|
async def git_commit(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Stage all changes and commit."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
message = data.get("message", "").strip()
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status_code=400, detail="Commit message is required")
|
||||||
|
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.commit(message)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "committed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/push")
|
||||||
|
async def git_push(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Push current branch."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.push()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "pushed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pull")
|
||||||
|
async def git_pull(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Pull current branch."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.pull()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "pulled"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fetch")
|
||||||
|
async def git_fetch(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Fetch from origin."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.fetch()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "fetched"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/checkout")
|
||||||
|
async def git_checkout(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Checkout a branch."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
branch = data.get("branch", "").strip()
|
||||||
|
if not branch:
|
||||||
|
raise HTTPException(status_code=400, detail="Branch name is required")
|
||||||
|
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.checkout(branch)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
workspace.branch = branch
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {"status": "checked_out", "branch": branch}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def git_history(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
path: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get commit history."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
commits = await git.history(path, limit)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"commits": [
|
||||||
|
{
|
||||||
|
"hash": c.hash,
|
||||||
|
"message": c.message,
|
||||||
|
"author": c.author,
|
||||||
|
"date": c.date,
|
||||||
|
}
|
||||||
|
for c in commits
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Workspace instance API endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_workspace(
|
||||||
|
session: AsyncSession,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> Workspace:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Workspace).where(
|
||||||
|
Workspace.id == workspace_id,
|
||||||
|
Workspace.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workspace = result.scalar_one_or_none()
|
||||||
|
if not workspace:
|
||||||
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_workspace_instances(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List tool instances using this workspace."""
|
||||||
|
await _get_workspace(session, workspace_id, user_id)
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance)
|
||||||
|
.where(ToolInstance.workspace_id == workspace_id)
|
||||||
|
.order_by(ToolInstance.created_at.desc())
|
||||||
|
)
|
||||||
|
instances = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(i.id),
|
||||||
|
"name": i.name,
|
||||||
|
"display_name": i.display_name,
|
||||||
|
"status": i.status,
|
||||||
|
"tool_type_id": str(i.tool_type_id),
|
||||||
|
"url": i.url,
|
||||||
|
"port": i.port,
|
||||||
|
"created_at": i.created_at.isoformat() if i.created_at else None,
|
||||||
|
}
|
||||||
|
for i in instances
|
||||||
|
]
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
"""Workspace CRUD API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import func, 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.git_repository import GitRepository
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
from src.services.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
|
||||||
|
all_workspaces_router = APIRouter(prefix="/workspaces")
|
||||||
|
|
||||||
|
|
||||||
|
@all_workspaces_router.get("/")
|
||||||
|
async def list_all_workspaces(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List all workspaces for the current user across all repos."""
|
||||||
|
instance_count = (
|
||||||
|
select(func.count(ToolInstance.id))
|
||||||
|
.where(ToolInstance.workspace_id == Workspace.id)
|
||||||
|
.correlate(Workspace)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
Workspace,
|
||||||
|
GitRepository.name.label("repo_name"),
|
||||||
|
GitRepository.project_id,
|
||||||
|
GitRepository.ssh_key_id.label("repo_ssh_key_id"),
|
||||||
|
instance_count.label("instance_count"),
|
||||||
|
)
|
||||||
|
.join(GitRepository, Workspace.repo_id == GitRepository.id)
|
||||||
|
.where(Workspace.user_id == user_id)
|
||||||
|
.order_by(Workspace.created_at.desc())
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(ws.id),
|
||||||
|
"name": ws.name,
|
||||||
|
"repo_id": str(ws.repo_id),
|
||||||
|
"repo_name": repo_name or "",
|
||||||
|
"repo_ssh_key_id": str(ssh_key_id) if ssh_key_id else None,
|
||||||
|
"project_id": str(project_id) if project_id else "",
|
||||||
|
"project_name": "",
|
||||||
|
"user_id": str(ws.user_id),
|
||||||
|
"branch": ws.branch,
|
||||||
|
"path": ws.path,
|
||||||
|
"status": ws.status,
|
||||||
|
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
|
||||||
|
"created_at": ws.created_at.isoformat() if ws.created_at else None,
|
||||||
|
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
|
||||||
|
"instance_count": count or 0,
|
||||||
|
}
|
||||||
|
for ws, repo_name, project_id, ssh_key_id, count in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@all_workspaces_router.delete("/{workspace_id}")
|
||||||
|
async def delete_workspace_top_level(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
force: bool = Query(False),
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Delete a workspace via top-level path."""
|
||||||
|
workspace = await session.get(Workspace, workspace_id)
|
||||||
|
if not workspace or workspace.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||||
|
|
||||||
|
manager = WorkspaceManager()
|
||||||
|
try:
|
||||||
|
await manager.delete(workspace, force=force, session=session)
|
||||||
|
await session.commit()
|
||||||
|
except WorkspaceHasInstancesError as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": "Workspace has running tool instances",
|
||||||
|
"instances": exc.instances,
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to delete workspace: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to delete workspace"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@all_workspaces_router.post("/")
|
||||||
|
async def create_workspace_top_level(
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a workspace directly (no nested project/repo path)."""
|
||||||
|
repo_id_str = data.get("repo_id", "").strip()
|
||||||
|
if not repo_id_str:
|
||||||
|
raise HTTPException(status_code=400, detail="repo_id is required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
repo_id = uuid.UUID(repo_id_str)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid repo_id format") from exc
|
||||||
|
|
||||||
|
repo = await session.get(GitRepository, repo_id)
|
||||||
|
if not repo or repo.owner_id != user_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Repository not found")
|
||||||
|
|
||||||
|
name = data.get("name", "").strip()
|
||||||
|
branch = data.get("branch", "main").strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="Workspace name is required")
|
||||||
|
|
||||||
|
manager = WorkspaceManager()
|
||||||
|
try:
|
||||||
|
workspace = await manager.create(repo, user_id, name, branch, session=session)
|
||||||
|
session.add(workspace)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to create workspace: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Workspace name already exists for this repository",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
await session.refresh(workspace)
|
||||||
|
return {
|
||||||
|
"id": str(workspace.id),
|
||||||
|
"name": workspace.name,
|
||||||
|
"repo_id": str(workspace.repo_id),
|
||||||
|
"branch": workspace.branch,
|
||||||
|
"path": workspace.path,
|
||||||
|
"status": workspace.status,
|
||||||
|
"created_at": workspace.created_at.isoformat()
|
||||||
|
if workspace.created_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_workspaces(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List workspaces for a repository, with instance counts."""
|
||||||
|
# Verify repo belongs to project and user
|
||||||
|
repo = await _get_repo(session, repo_id, project_id, user_id)
|
||||||
|
|
||||||
|
# Build subquery for instance counts
|
||||||
|
instance_count = (
|
||||||
|
select(func.count(ToolInstance.id))
|
||||||
|
.where(ToolInstance.workspace_id == Workspace.id)
|
||||||
|
.correlate(Workspace)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
Workspace,
|
||||||
|
instance_count.label("instance_count"),
|
||||||
|
)
|
||||||
|
.where(Workspace.repo_id == repo_id)
|
||||||
|
.order_by(Workspace.created_at.desc())
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(ws.id),
|
||||||
|
"name": ws.name,
|
||||||
|
"repo_id": str(ws.repo_id),
|
||||||
|
"repo_name": repo.name,
|
||||||
|
"repo_ssh_key_id": str(repo.ssh_key_id) if repo.ssh_key_id else None,
|
||||||
|
"project_id": str(repo.project_id) if repo.project_id else "",
|
||||||
|
"project_name": repo.project.name if repo.project else "",
|
||||||
|
"user_id": str(ws.user_id),
|
||||||
|
"branch": ws.branch,
|
||||||
|
"path": ws.path,
|
||||||
|
"status": ws.status,
|
||||||
|
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
|
||||||
|
"created_at": ws.created_at.isoformat() if ws.created_at else None,
|
||||||
|
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
|
||||||
|
"instance_count": count or 0,
|
||||||
|
}
|
||||||
|
for ws, count in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def create_workspace(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new workspace by cloning a repository branch."""
|
||||||
|
repo = await _get_repo(session, repo_id, project_id, user_id)
|
||||||
|
|
||||||
|
name = data.get("name", "").strip()
|
||||||
|
branch = data.get("branch", "main").strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="Workspace name is required")
|
||||||
|
if not branch:
|
||||||
|
raise HTTPException(status_code=400, detail="Branch is required")
|
||||||
|
|
||||||
|
manager = WorkspaceManager()
|
||||||
|
try:
|
||||||
|
workspace = await manager.create(repo, user_id, name, branch, session=session)
|
||||||
|
session.add(workspace)
|
||||||
|
await session.commit()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except ValueError as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to create workspace: %s", exc)
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to create workspace: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Workspace name already exists for this repository",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
await session.refresh(workspace)
|
||||||
|
return {
|
||||||
|
"id": str(workspace.id),
|
||||||
|
"name": workspace.name,
|
||||||
|
"repo_id": str(workspace.repo_id),
|
||||||
|
"branch": workspace.branch,
|
||||||
|
"path": workspace.path,
|
||||||
|
"status": workspace.status,
|
||||||
|
"created_at": workspace.created_at.isoformat()
|
||||||
|
if workspace.created_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{workspace_id}")
|
||||||
|
async def get_workspace_detail(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get workspace details."""
|
||||||
|
repo = await _get_repo(session, repo_id, project_id, user_id)
|
||||||
|
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||||
|
|
||||||
|
# Count instances
|
||||||
|
result = await session.execute(
|
||||||
|
select(func.count(ToolInstance.id)).where(
|
||||||
|
ToolInstance.workspace_id == workspace_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
instance_count = result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(workspace.id),
|
||||||
|
"name": workspace.name,
|
||||||
|
"repo_id": str(workspace.repo_id),
|
||||||
|
"repo_name": repo.name,
|
||||||
|
"user_id": str(workspace.user_id),
|
||||||
|
"branch": workspace.branch,
|
||||||
|
"path": workspace.path,
|
||||||
|
"status": workspace.status,
|
||||||
|
"last_sync_at": workspace.last_sync_at.isoformat()
|
||||||
|
if workspace.last_sync_at
|
||||||
|
else None,
|
||||||
|
"created_at": workspace.created_at.isoformat()
|
||||||
|
if workspace.created_at
|
||||||
|
else None,
|
||||||
|
"updated_at": workspace.updated_at.isoformat()
|
||||||
|
if workspace.updated_at
|
||||||
|
else None,
|
||||||
|
"instance_count": instance_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{workspace_id}")
|
||||||
|
async def update_workspace(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update workspace name or branch."""
|
||||||
|
await _get_repo(session, repo_id, project_id, user_id)
|
||||||
|
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||||
|
|
||||||
|
new_name = data.get("name", "").strip()
|
||||||
|
new_branch = data.get("branch", "").strip()
|
||||||
|
|
||||||
|
if new_name:
|
||||||
|
workspace.name = new_name
|
||||||
|
if new_branch:
|
||||||
|
workspace.branch = new_branch
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to update workspace: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Workspace name already exists for this repository",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(workspace.id),
|
||||||
|
"name": workspace.name,
|
||||||
|
"branch": workspace.branch,
|
||||||
|
"status": workspace.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{workspace_id}")
|
||||||
|
async def delete_workspace(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
force: bool = Query(False),
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Delete a workspace. Returns 409 if instances exist and force=False."""
|
||||||
|
await _get_repo(session, repo_id, project_id, user_id)
|
||||||
|
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||||
|
|
||||||
|
manager = WorkspaceManager()
|
||||||
|
try:
|
||||||
|
await manager.delete(workspace, force=force, session=session)
|
||||||
|
await session.commit()
|
||||||
|
except WorkspaceHasInstancesError as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": "Workspace has running tool instances",
|
||||||
|
"instances": exc.instances,
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to delete workspace: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to delete workspace"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{workspace_id}/sync")
|
||||||
|
async def sync_workspace(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Sync workspace with remote. Returns 409 if branch was deleted."""
|
||||||
|
await _get_repo(session, repo_id, project_id, user_id)
|
||||||
|
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||||
|
|
||||||
|
manager = WorkspaceManager()
|
||||||
|
result = await manager.sync(workspace, session=session)
|
||||||
|
|
||||||
|
if result.branch_deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": f"Branch '{workspace.branch}' was deleted from remote",
|
||||||
|
"branch_deleted": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"branch_deleted": False,
|
||||||
|
"pulled": True,
|
||||||
|
"last_sync_at": workspace.last_sync_at.isoformat()
|
||||||
|
if workspace.last_sync_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_repo(
|
||||||
|
session: AsyncSession,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> GitRepository:
|
||||||
|
"""Fetch and validate repository access."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(GitRepository)
|
||||||
|
.where(
|
||||||
|
GitRepository.id == repo_id,
|
||||||
|
GitRepository.project_id == project_id,
|
||||||
|
)
|
||||||
|
.options(selectinload(GitRepository.project))
|
||||||
|
)
|
||||||
|
repo = result.scalar_one_or_none()
|
||||||
|
if not repo:
|
||||||
|
raise HTTPException(status_code=404, detail="Repository not found")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_workspace(
|
||||||
|
session: AsyncSession,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
) -> Workspace:
|
||||||
|
"""Fetch and validate workspace."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(Workspace).where(
|
||||||
|
Workspace.id == workspace_id,
|
||||||
|
Workspace.repo_id == repo_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workspace = result.scalar_one_or_none()
|
||||||
|
if not workspace:
|
||||||
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||||
|
return workspace
|
||||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.auth.session import decode_session_cookie
|
from src.auth.session import decode_session_cookie
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
|
from src.models.project import Project
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -47,3 +48,39 @@ async def get_current_user(
|
|||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_owned_project(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> "Project":
|
||||||
|
"""Fetch a project and verify ownership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The project if found and owned by the user.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if project not found, 403 if user is not the owner.
|
||||||
|
"""
|
||||||
|
from src.models.project import Project
|
||||||
|
|
||||||
|
project = await session.get(Project, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||||
|
if project.owner_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
||||||
|
return project
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import hmac
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
@@ -23,7 +23,7 @@ def create_session_cookie(*, settings: Settings, user_id: str) -> str:
|
|||||||
"""Create a signed session cookie value."""
|
"""Create a signed session cookie value."""
|
||||||
payload = {
|
payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
|
"exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
|
||||||
}
|
}
|
||||||
|
|
||||||
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
|
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
|
||||||
@@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str,
|
|||||||
payload = json.loads(payload_bytes)
|
payload = json.loads(payload_bytes)
|
||||||
|
|
||||||
# Check expiry
|
# Check expiry
|
||||||
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()):
|
if payload.get("exp", 0) < int(datetime.now(timezone.utc).timestamp()):
|
||||||
raise ValueError("session expired")
|
raise ValueError("session expired")
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ class Settings(BaseSettings):
|
|||||||
# Repository storage
|
# Repository storage
|
||||||
repo_base_path: str = "/data/repos"
|
repo_base_path: str = "/data/repos"
|
||||||
|
|
||||||
|
# Tool instance storage
|
||||||
|
instance_base_path: str = "/data/instances"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -1,15 +1,52 @@
|
|||||||
|
"""Structured JSON logging configuration."""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from src.services.correlation import get_correlation_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CorrelationIdFilter(logging.Filter):
|
||||||
|
"""Inject correlation_id into every log record from context var."""
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
record.correlation_id = get_correlation_id() # type: ignore[attr-defined]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class JSONFormatter(logging.Formatter):
|
||||||
|
"""Emit log records as single-line JSON."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
log_obj: dict = {
|
||||||
|
"timestamp": self.formatTime(record),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
"correlation_id": getattr(record, "correlation_id", None),
|
||||||
|
}
|
||||||
|
# Optional extra fields
|
||||||
|
for key in ("instance_id", "event_type"):
|
||||||
|
value = getattr(record, key, None)
|
||||||
|
if value is not None:
|
||||||
|
log_obj[key] = value
|
||||||
|
if record.exc_info:
|
||||||
|
log_obj["exception"] = self.formatException(record.exc_info)
|
||||||
|
return json.dumps(log_obj, default=str)
|
||||||
|
|
||||||
|
def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created))
|
||||||
|
|
||||||
|
|
||||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||||
"""Log all HTTP requests with timing and status codes."""
|
"""Log all HTTP requests with timing and status codes."""
|
||||||
|
|
||||||
@@ -17,7 +54,6 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
client_host = request.client.host if request.client else "unknown"
|
client_host = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
# Log the incoming request
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"→ Request: %s %s (client: %s)",
|
"→ Request: %s %s (client: %s)",
|
||||||
request.method,
|
request.method,
|
||||||
@@ -29,7 +65,6 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
|
|
||||||
# Log the response
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"← Response: %s %s → %d (%dms)",
|
"← Response: %s %s → %d (%dms)",
|
||||||
request.method,
|
request.method,
|
||||||
@@ -69,15 +104,13 @@ class ExceptionLoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
|
|
||||||
def configure_logging(level: int = logging.INFO) -> None:
|
def configure_logging(level: int = logging.INFO) -> None:
|
||||||
"""Configure structured logging for the application."""
|
"""Configure structured JSON logging for the application."""
|
||||||
formatter = logging.Formatter(
|
formatter = JSONFormatter()
|
||||||
fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Console handler
|
# Console handler
|
||||||
console_handler = logging.StreamHandler(sys.stdout)
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
console_handler.setFormatter(formatter)
|
console_handler.setFormatter(formatter)
|
||||||
|
console_handler.addFilter(CorrelationIdFilter())
|
||||||
|
|
||||||
# Configure root logger
|
# Configure root logger
|
||||||
root_logger = logging.getLogger()
|
root_logger = logging.getLogger()
|
||||||
|
|||||||
+101
-97
@@ -1,27 +1,45 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import select, text
|
|
||||||
|
|
||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
from src.api.dashboard import router as dashboard_router
|
from src.api.dashboard import router as dashboard_router
|
||||||
|
from src.api.events import router as events_router
|
||||||
from src.api.git_repositories import router as git_repositories_router
|
from src.api.git_repositories import router as git_repositories_router
|
||||||
|
from src.api.health import router as health_router
|
||||||
from src.api.projects import router as projects_router
|
from src.api.projects import router as projects_router
|
||||||
from src.api.ssh_keys import router as ssh_keys_router
|
from src.api.ssh_keys import router as ssh_keys_router
|
||||||
|
from src.api.terminal import router as terminal_router
|
||||||
|
from src.api.instance_proxy import router as instance_proxy_router
|
||||||
|
from src.api.config_profiles import router as config_profiles_router
|
||||||
|
from src.api.tool_definitions import router as tool_definitions_router
|
||||||
|
from src.api.tool_instances import router as tool_instances_router
|
||||||
|
from src.api.tool_instances import sessions_router
|
||||||
from src.api.tool_types import router as tool_types_router
|
from src.api.tool_types import router as tool_types_router
|
||||||
|
from src.api.notifications import router as notifications_router
|
||||||
from src.api.user_config import router as user_config_router
|
from src.api.user_config import router as user_config_router
|
||||||
from src.api.users import router as users_router
|
from src.api.users import router as users_router
|
||||||
|
from src.api.workspace_files import router as workspace_files_router
|
||||||
|
from src.api.workspace_git import router as workspace_git_router
|
||||||
|
from src.api.workspace_instances import router as workspace_instances_router
|
||||||
|
from src.api.workspaces import all_workspaces_router, router as workspaces_router
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.database import SessionLocal, init_database
|
from src.models.notification import Notification # noqa: F401 – Alembic model discovery
|
||||||
|
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
||||||
|
from src.database import init_database
|
||||||
from src.logging_config import (
|
from src.logging_config import (
|
||||||
ExceptionLoggingMiddleware,
|
ExceptionLoggingMiddleware,
|
||||||
RequestLoggingMiddleware,
|
RequestLoggingMiddleware,
|
||||||
configure_logging,
|
configure_logging,
|
||||||
)
|
)
|
||||||
from src.models.tool_type import ToolType
|
from src.services.correlation import CorrelationIdMiddleware
|
||||||
|
from src.services.event_bus import InstanceEventBus
|
||||||
|
from src.services.health_monitor import HealthMonitor
|
||||||
|
|
||||||
# Configure logging early
|
# Configure logging early
|
||||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
@@ -32,102 +50,73 @@ settings = Settings()
|
|||||||
app = FastAPI(title="Headquarter API")
|
app = FastAPI(title="Headquarter API")
|
||||||
|
|
||||||
# Configure CORS - must be before other middleware
|
# Configure CORS - must be before other middleware
|
||||||
|
# Build allowed origins list including web and api domains
|
||||||
|
cors_origins = [settings.web_base_url]
|
||||||
|
if settings.api_base_url != settings.web_base_url:
|
||||||
|
cors_origins.append(settings.api_base_url)
|
||||||
|
logger.info("CORS configured with origins: %s", cors_origins)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[settings.web_base_url],
|
allow_origins=cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.add_middleware(CorrelationIdMiddleware)
|
||||||
app.add_middleware(RequestLoggingMiddleware)
|
app.add_middleware(RequestLoggingMiddleware)
|
||||||
app.add_middleware(ExceptionLoggingMiddleware)
|
app.add_middleware(ExceptionLoggingMiddleware)
|
||||||
|
|
||||||
|
|
||||||
async def _table_exists(session, table_name: str) -> bool:
|
def _sanitize_validation_errors(errors):
|
||||||
"""Check if a table exists in the database."""
|
"""Convert validation errors to JSON-safe format."""
|
||||||
try:
|
sanitized = []
|
||||||
result = await session.execute(
|
for error in errors:
|
||||||
text("""
|
safe_error = {
|
||||||
SELECT EXISTS (
|
"type": error.get("type"),
|
||||||
SELECT FROM information_schema.tables
|
"loc": error.get("loc"),
|
||||||
WHERE table_schema = 'public'
|
"msg": error.get("msg"),
|
||||||
AND table_name = :table_name
|
"input": str(error.get("input"))
|
||||||
)
|
if error.get("input") is not None
|
||||||
"""),
|
else None,
|
||||||
{"table_name": table_name},
|
}
|
||||||
)
|
# Convert ctx to safe format
|
||||||
return result.scalar() or False
|
ctx = error.get("ctx")
|
||||||
except Exception:
|
if ctx:
|
||||||
return False
|
safe_ctx = {}
|
||||||
|
for key, value in ctx.items():
|
||||||
|
if isinstance(value, Exception):
|
||||||
|
safe_ctx[key] = str(value)
|
||||||
|
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||||
|
safe_ctx[key] = value
|
||||||
|
else:
|
||||||
|
safe_ctx[key] = str(value)
|
||||||
|
safe_error["ctx"] = safe_ctx
|
||||||
|
sanitized.append(safe_error)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
async def seed_builtin_tool_types():
|
@app.exception_handler(RequestValidationError)
|
||||||
async with SessionLocal() as session:
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
# Check if tool_types table exists before attempting to seed
|
"""Log validation errors and return detailed response."""
|
||||||
if not await _table_exists(session, "tool_types"):
|
errors = exc.errors()
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"tool_types table does not exist. Skipping seeding. "
|
"Validation error for %s %s: %s",
|
||||||
"Migrations may not have run yet."
|
request.method,
|
||||||
)
|
request.url.path,
|
||||||
return
|
errors,
|
||||||
|
)
|
||||||
|
safe_errors = _sanitize_validation_errors(errors)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={"detail": safe_errors},
|
||||||
|
)
|
||||||
|
|
||||||
builtin_types = [
|
|
||||||
{
|
|
||||||
"name": "code-server",
|
|
||||||
"display_name": "VS Code Server",
|
|
||||||
"description": "VS Code running in the browser via code-server",
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
code-server:
|
|
||||||
image: lscr.io/linuxserver/code-server:latest
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
environment:
|
|
||||||
- PUID=1000
|
|
||||||
- PGID=1000
|
|
||||||
- TZ=Europe/London
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/config/workspace
|
|
||||||
ports:
|
|
||||||
- "8443:8443"
|
|
||||||
restart: unless-stopped""",
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "jupyter-notebook",
|
|
||||||
"display_name": "Jupyter Notebook",
|
|
||||||
"description": "Jupyter Lab for interactive development",
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
jupyter:
|
|
||||||
image: jupyter/scipy-notebook:latest
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
environment:
|
|
||||||
- JUPYTER_ENABLE_LAB=yes
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/home/jovyan/work
|
|
||||||
ports:
|
|
||||||
- "8888:8888"
|
|
||||||
restart: unless-stopped""",
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for tool_data in builtin_types:
|
# Global services
|
||||||
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
_event_bus = InstanceEventBus()
|
||||||
if not existing:
|
_health_monitor = HealthMonitor(_event_bus)
|
||||||
tool_type = ToolType(
|
|
||||||
name=tool_data["name"],
|
|
||||||
display_name=tool_data["display_name"],
|
|
||||||
description=tool_data["description"],
|
|
||||||
compose_template=tool_data["compose_template"],
|
|
||||||
required_variables=tool_data["required_variables"],
|
|
||||||
is_builtin=True,
|
|
||||||
)
|
|
||||||
session.add(tool_type)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
logger.info("Built-in tool types seeded successfully.")
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
@@ -139,23 +128,25 @@ async def on_startup():
|
|||||||
if not db_ready:
|
if not db_ready:
|
||||||
logger.error("Database initialization failed. Shutting down.")
|
logger.error("Database initialization failed. Shutting down.")
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Seed built-in data
|
# Start background health monitor
|
||||||
await seed_builtin_tool_types()
|
_health_monitor.start()
|
||||||
|
logger.info("Health monitor started")
|
||||||
|
|
||||||
logger.info("Startup complete.")
|
logger.info("Startup complete.")
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health_check():
|
|
||||||
try:
|
|
||||||
from sqlalchemy import text
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
await session.execute(text("SELECT 1"))
|
|
||||||
return {"status": "healthy", "database": "connected"}
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Health check failed: %s", exc)
|
|
||||||
return {"status": "unhealthy", "database": "disconnected", "error": str(exc)}
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def on_shutdown():
|
||||||
|
logger.info("Shutting down Headquarter API...")
|
||||||
|
_health_monitor.stop()
|
||||||
|
logger.info("Health monitor stopped")
|
||||||
|
logger.info("Shutdown complete.")
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(health_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
app.include_router(projects_router)
|
app.include_router(projects_router)
|
||||||
@@ -164,4 +155,17 @@ app.include_router(ssh_keys_router)
|
|||||||
app.include_router(git_repositories_router)
|
app.include_router(git_repositories_router)
|
||||||
app.include_router(user_config_router)
|
app.include_router(user_config_router)
|
||||||
app.include_router(tool_types_router)
|
app.include_router(tool_types_router)
|
||||||
|
app.include_router(tool_definitions_router)
|
||||||
|
app.include_router(config_profiles_router)
|
||||||
|
app.include_router(tool_instances_router)
|
||||||
|
app.include_router(sessions_router)
|
||||||
|
app.include_router(instance_proxy_router)
|
||||||
|
app.include_router(terminal_router)
|
||||||
|
app.include_router(events_router)
|
||||||
|
app.include_router(notifications_router)
|
||||||
|
app.include_router(all_workspaces_router)
|
||||||
|
app.include_router(workspaces_router)
|
||||||
|
app.include_router(workspace_files_router)
|
||||||
|
app.include_router(workspace_git_router)
|
||||||
|
app.include_router(workspace_instances_router)
|
||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.health_check import HealthCheck
|
||||||
|
from src.models.instance_event import InstanceEvent
|
||||||
|
from src.models.notification import Notification
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
|
from src.models.terminal_session import TerminalSessionModel
|
||||||
|
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
|
||||||
__all__ = ["Base", "GitRepository", "Project", "SSHKey", "ToolType", "User", "UserConfig"]
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"ConfigProfile",
|
||||||
|
"ConfigProfileInclude",
|
||||||
|
"GitRepository",
|
||||||
|
"HealthCheck",
|
||||||
|
"InstanceEvent",
|
||||||
|
"Notification",
|
||||||
|
"Project",
|
||||||
|
"SSHKey",
|
||||||
|
"TerminalSessionModel",
|
||||||
|
"ToolDefinitionManifest",
|
||||||
|
"ToolInstance",
|
||||||
|
"ToolType",
|
||||||
|
"User",
|
||||||
|
"UserConfig",
|
||||||
|
"Workspace",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_profiles"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
env_vars: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"VAR_NAME": "value", ...}
|
||||||
|
runtime_hints: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"start_command": "...", "working_dir": "...", ...}
|
||||||
|
mounts: Mapped[list] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
) # [{"target": "/path", "mode": "rw", "files": {"rel/path": "content"}}, ...]
|
||||||
|
files: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"rel/path": "content", ...}
|
||||||
|
git_mounts: Mapped[list] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship()
|
||||||
|
project: Mapped["Project | None"] = relationship()
|
||||||
|
tool_type: Mapped["ToolType | None"] = relationship()
|
||||||
|
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
|
||||||
|
"ConfigProfileInclude",
|
||||||
|
foreign_keys="ConfigProfileInclude.profile_id",
|
||||||
|
order_by="ConfigProfileInclude.order_index",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_profile_includes"
|
||||||
|
|
||||||
|
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[profile_id],
|
||||||
|
back_populates="includes",
|
||||||
|
)
|
||||||
|
included_profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[included_profile_id],
|
||||||
|
)
|
||||||
@@ -10,6 +10,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -18,11 +19,15 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(255))
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
path: Mapped[str] = mapped_column(String(1024))
|
path: Mapped[str] = mapped_column(String(1024))
|
||||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=False)
|
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=True)
|
||||||
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
|
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
|
||||||
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
ssh_key_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("ssh_keys.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
project: Mapped["Project"] = relationship(back_populates="repositories")
|
project: Mapped["Project"] = relationship(back_populates="repositories")
|
||||||
owner: Mapped["User"] = relationship()
|
owner: Mapped["User"] = relationship()
|
||||||
|
ssh_key: Mapped["SSHKey | None"] = relationship()
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""SQLAlchemy model for health check snapshots."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, Uuid, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.models.base import Base, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheck(UUIDPrimaryKeyMixin, Base):
|
||||||
|
__tablename__ = "health_checks"
|
||||||
|
|
||||||
|
instance_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("tool_instances.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
container_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
container_healthy: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
tunnel_healthy: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
probe_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
probe_output: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
checked_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""SQLAlchemy model for instance lifecycle event audit rows."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, JSON, String, Text, Uuid, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.models.base import Base, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceEvent(UUIDPrimaryKeyMixin, Base):
|
||||||
|
__tablename__ = "instance_events"
|
||||||
|
|
||||||
|
instance_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("tool_instances.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
event_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata",
|
||||||
|
JSON,
|
||||||
|
nullable=False,
|
||||||
|
default=dict,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Notification SQLAlchemy model."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, JSON, String, Text
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from src.models.base import Base, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(UUIDPrimaryKeyMixin, Base):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
category: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
severity: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
source_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), nullable=True
|
||||||
|
)
|
||||||
|
notification_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata", JSON, nullable=False, default=dict
|
||||||
|
)
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, index=True
|
||||||
|
)
|
||||||
|
dismissed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Terminal session database model."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalSessionModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
"""Database model for terminal session metadata."""
|
||||||
|
|
||||||
|
__tablename__ = "terminal_sessions"
|
||||||
|
|
||||||
|
instance_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(),
|
||||||
|
ForeignKey("tool_instances.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
default="active",
|
||||||
|
)
|
||||||
|
last_activity_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
closed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Tool Definition Manifest model."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ToolDefinitionManifest(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
"""A declarative manifest that compiles to Dockerfile + Compose.
|
||||||
|
|
||||||
|
Can be either:
|
||||||
|
- A base definition (is_base=True) with a FROM image and common packages
|
||||||
|
- A tool definition (is_base=False) that references a base + adds specifics
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "tool_definition_manifests"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
category: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
interface_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
|
||||||
|
# Base: either a direct image or a reference to another manifest
|
||||||
|
base_image: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
base_definition_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(),
|
||||||
|
ForeignKey("tool_definition_manifests.id"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
base_version: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, default="latest"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The full manifest JSON
|
||||||
|
manifest: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
|
||||||
|
# Caches for quick inspection
|
||||||
|
dockerfile_cache: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
compose_cache: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Versioning
|
||||||
|
version: Mapped[str] = mapped_column(String(32), nullable=False, default="v1")
|
||||||
|
is_base: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(),
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
created_by: Mapped["User | None"] = relationship(
|
||||||
|
foreign_keys=[created_by_id],
|
||||||
|
)
|
||||||
|
base_definition: Mapped["ToolDefinitionManifest | None"] = relationship(
|
||||||
|
remote_side="ToolDefinitionManifest.id",
|
||||||
|
foreign_keys=[base_definition_id],
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
|
||||||
|
|
||||||
|
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "tool_instances"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
tool_type_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("tool_types.id"), nullable=False
|
||||||
|
)
|
||||||
|
repository_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("git_repositories.id"), nullable=False
|
||||||
|
)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("projects.id"), nullable=False
|
||||||
|
)
|
||||||
|
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("users.id"), nullable=False
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
|
||||||
|
container_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
container_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
compose_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
public_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
tunnel_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
last_started_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
manifest_compiled_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
image_tag: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
probe_result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
clone_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="mount")
|
||||||
|
branch: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True, default="main"
|
||||||
|
)
|
||||||
|
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
workspace_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("workspaces.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
|
workspace: Mapped["Workspace | None"] = relationship()
|
||||||
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
|
project: Mapped["Project"] = relationship()
|
||||||
|
owner: Mapped["User"] = relationship()
|
||||||
|
selected_config_profile: Mapped["ConfigProfile | None"] = relationship()
|
||||||
@@ -7,6 +7,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
@@ -17,13 +19,37 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
compose_template: Mapped[str] = mapped_column(Text, nullable=False)
|
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||||
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
interface_type: Mapped[str] = mapped_column(
|
||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
String(20), nullable=False, default="web"
|
||||||
|
)
|
||||||
|
requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
default_port: Mapped[int] = mapped_column(nullable=False)
|
||||||
|
definition_type: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, default="legacy"
|
||||||
|
) # "legacy" | "manifest"
|
||||||
|
manifest_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(),
|
||||||
|
ForeignKey("tool_definition_manifests.id"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
compose_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
dockerfile_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
build_context: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=True
|
||||||
|
)
|
||||||
|
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
startup_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
required_variables: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
)
|
||||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(),
|
UUID(),
|
||||||
ForeignKey("users.id"),
|
ForeignKey("users.id"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
manifest: Mapped["ToolDefinitionManifest | None"] = relationship(
|
||||||
|
foreign_keys=[manifest_id],
|
||||||
|
)
|
||||||
created_by: Mapped["User | None"] = relationship()
|
created_by: Mapped["User | None"] = relationship()
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Workspace model for persistent writable repo clones."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class Workspace(Base, TimestampMixin):
|
||||||
|
"""A persistent, writable local clone of a Git repository.
|
||||||
|
|
||||||
|
Users create workspaces explicitly, then start tool instances on them.
|
||||||
|
Multiple tool instances can share the same workspace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "workspaces"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("git_repositories.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
branch: Mapped[str] = mapped_column(String(255), nullable=False, default="main")
|
||||||
|
path: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="ready")
|
||||||
|
last_sync_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
repository: Mapped[GitRepository] = relationship("GitRepository")
|
||||||
|
owner: Mapped[User] = relationship("User")
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Clone service for repository cloning and dirty state checking."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def clone_repository(
|
||||||
|
remote_url: str,
|
||||||
|
ssh_key_path: str | None,
|
||||||
|
instance_dir: str,
|
||||||
|
branch: str = "main",
|
||||||
|
) -> str:
|
||||||
|
"""Clone a git repository into the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
remote_url: Git remote URL (SSH or HTTPS)
|
||||||
|
ssh_key_path: Path to SSH private key for authentication (optional)
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
branch: Branch to clone (default: main)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the cloned repository
|
||||||
|
"""
|
||||||
|
clone_path = Path(instance_dir) / "repo-clone"
|
||||||
|
clone_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
if ssh_key_path:
|
||||||
|
# Use SSH key for cloning
|
||||||
|
env["GIT_SSH_COMMAND"] = f"ssh -i {ssh_key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"--branch", branch,
|
||||||
|
"--single-branch",
|
||||||
|
remote_url,
|
||||||
|
str(clone_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.debug("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Git clone failed: %s", result.stderr)
|
||||||
|
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
|
||||||
|
|
||||||
|
logger.debug("Successfully cloned repository into %s", clone_path)
|
||||||
|
return str(clone_path)
|
||||||
|
|
||||||
|
|
||||||
|
def check_dirty_state(clone_path: str) -> tuple[bool, list[str]]:
|
||||||
|
"""Check for uncommitted changes in a cloned repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
clone_path: Path to the cloned repository
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_dirty, list_of_changed_files)
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", clone_path, "status", "--short"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning("Failed to check git status: %s", result.stderr)
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
changed_files = [line.strip() for line in result.stdout.split("\n") if line.strip()]
|
||||||
|
is_dirty = len(changed_files) > 0
|
||||||
|
|
||||||
|
return is_dirty, changed_files
|
||||||
|
|
||||||
|
|
||||||
|
def remove_clone_directory(instance_dir: str) -> None:
|
||||||
|
"""Remove the cloned repository from the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
"""
|
||||||
|
clone_path = Path(instance_dir) / "repo-clone"
|
||||||
|
if clone_path.exists():
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(clone_path)
|
||||||
|
logger.debug("Removed clone directory: %s", clone_path)
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
"""Config profile resolver service.
|
||||||
|
|
||||||
|
Provides recursive ordered include resolution with deterministic merge rules
|
||||||
|
and cycle protection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileCycleError(Exception):
|
||||||
|
"""Raised when a cycle is detected in profile includes."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileNotFoundError(Exception):
|
||||||
|
"""Raised when a referenced profile is not found."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedMount:
|
||||||
|
"""A resolved mount with merged files and final mode."""
|
||||||
|
|
||||||
|
target: str
|
||||||
|
mode: str
|
||||||
|
files: dict[str, str] = field(default_factory=dict)
|
||||||
|
overridden_files: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedProfile:
|
||||||
|
"""The fully resolved output of a config profile."""
|
||||||
|
|
||||||
|
profile_id: uuid.UUID
|
||||||
|
profile_name: str
|
||||||
|
env_vars: dict[str, str] = field(default_factory=dict)
|
||||||
|
runtime_hints: dict[str, Any] = field(default_factory=dict)
|
||||||
|
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
||||||
|
git_mounts: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
files: dict[str, str] = field(default_factory=dict)
|
||||||
|
env_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
hint_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
file_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
mount_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
included_profiles: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_cycle(
|
||||||
|
profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]
|
||||||
|
) -> bool:
|
||||||
|
"""Detect if adding profile_id to path would create a cycle.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_id: The profile ID to check.
|
||||||
|
visited: Set of already-visited profile IDs in current resolution.
|
||||||
|
path: Current resolution path for error reporting.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a cycle would be created.
|
||||||
|
"""
|
||||||
|
if profile_id in visited:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_env_vars(
|
||||||
|
base: dict[str, str],
|
||||||
|
overlay: dict[str, str],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Merge env vars, tracking overrides.
|
||||||
|
|
||||||
|
Later values replace earlier values.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for key, value in overlay.items():
|
||||||
|
if key in result and result[key] != value:
|
||||||
|
overrides[key] = source_name
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_runtime_hints(
|
||||||
|
base: dict[str, Any],
|
||||||
|
overlay: dict[str, Any],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Merge runtime hints, tracking overrides.
|
||||||
|
|
||||||
|
Later values replace earlier values.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for key, value in overlay.items():
|
||||||
|
if key in result and result[key] != value:
|
||||||
|
overrides[key] = source_name
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_files(
|
||||||
|
base: dict[str, str],
|
||||||
|
overlay: dict[str, str],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Merge file maps, tracking overrides.
|
||||||
|
|
||||||
|
Later relative file paths win.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for path, content in overlay.items():
|
||||||
|
if path in result and result[path] != content:
|
||||||
|
overrides[path] = source_name
|
||||||
|
result[path] = content
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_mounts(
|
||||||
|
base: dict[str, ResolvedMount],
|
||||||
|
overlay: list[dict[str, Any]],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, ResolvedMount]:
|
||||||
|
"""Merge mounts, tracking overrides.
|
||||||
|
|
||||||
|
Mounts with the same target path have their file maps merged and later
|
||||||
|
relative file paths win. Mode conflicts: later layer wins.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for mount_data in overlay:
|
||||||
|
target = mount_data["target"]
|
||||||
|
mode = mount_data.get("mode", "rw")
|
||||||
|
files = mount_data.get("files", {})
|
||||||
|
|
||||||
|
if target in result:
|
||||||
|
existing = result[target]
|
||||||
|
merged_files = dict(existing.files)
|
||||||
|
file_overrides = dict(existing.overridden_files)
|
||||||
|
for rel_path, content in files.items():
|
||||||
|
if rel_path in merged_files and merged_files[rel_path] != content:
|
||||||
|
file_overrides[rel_path] = source_name
|
||||||
|
merged_files[rel_path] = content
|
||||||
|
if existing.mode != mode:
|
||||||
|
overrides[target] = source_name
|
||||||
|
result[target] = ResolvedMount(
|
||||||
|
target=target,
|
||||||
|
mode=mode,
|
||||||
|
files=merged_files,
|
||||||
|
overridden_files=file_overrides,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result[target] = ResolvedMount(
|
||||||
|
target=target,
|
||||||
|
mode=mode,
|
||||||
|
files=dict(files),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_git_mounts(
|
||||||
|
base: list[dict[str, Any]],
|
||||||
|
overlay: list[dict[str, Any]],
|
||||||
|
source_name: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Merge git mounts from included profiles.
|
||||||
|
|
||||||
|
Entries with the same remote_url + branch have their mappings concatenated.
|
||||||
|
Different repos are kept as separate entries.
|
||||||
|
All entries are normalized to the mappings format.
|
||||||
|
"""
|
||||||
|
result = list(base)
|
||||||
|
# Normalize existing entries to mappings format
|
||||||
|
for i, m in enumerate(result):
|
||||||
|
result[i] = _normalize_git_mount_entry(dict(m))
|
||||||
|
|
||||||
|
# Build lookup by (remote_url, branch)
|
||||||
|
seen = {}
|
||||||
|
for i, m in enumerate(result):
|
||||||
|
key = (m["remote_url"], m.get("branch"))
|
||||||
|
seen[key] = i
|
||||||
|
|
||||||
|
for mount in overlay:
|
||||||
|
mount = _normalize_git_mount_entry(dict(mount))
|
||||||
|
key = (mount["remote_url"], mount.get("branch"))
|
||||||
|
if key in seen:
|
||||||
|
# Same repo+branch: concatenate mappings, dedup by (source_path, target_path)
|
||||||
|
existing = result[seen[key]]
|
||||||
|
existing_sources = {
|
||||||
|
(m["source_path"], m["target_path"])
|
||||||
|
for m in existing.get("mappings", [])
|
||||||
|
}
|
||||||
|
for mapping in mount.get("mappings", []):
|
||||||
|
map_key = (mapping["source_path"], mapping["target_path"])
|
||||||
|
if map_key not in existing_sources:
|
||||||
|
existing["mappings"].append(dict(mapping))
|
||||||
|
existing_sources.add(map_key)
|
||||||
|
else:
|
||||||
|
seen[key] = len(result)
|
||||||
|
result.append(mount)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_git_mount_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Normalize a git mount entry to the unified mappings format.
|
||||||
|
|
||||||
|
Converts legacy source_path + target_path into a single-entry mappings array.
|
||||||
|
"""
|
||||||
|
entry = dict(entry)
|
||||||
|
if "mappings" not in entry or not entry.get("mappings"):
|
||||||
|
source = entry.get("source_path", ".")
|
||||||
|
target = entry.get("target_path")
|
||||||
|
if target is not None:
|
||||||
|
entry["mappings"] = [{"source_path": source, "target_path": target}]
|
||||||
|
# Remove legacy fields once normalized
|
||||||
|
entry.pop("source_path", None)
|
||||||
|
entry.pop("target_path", None)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_profile_recursive(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
visited: set[uuid.UUID],
|
||||||
|
path: list[uuid.UUID],
|
||||||
|
) -> ResolvedProfile:
|
||||||
|
"""Recursively resolve a profile and its includes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
profile_id: Profile ID to resolve.
|
||||||
|
visited: Set of already-visited profile IDs in current resolution chain.
|
||||||
|
path: Current resolution path for error reporting.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolvedProfile with all includes merged.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigProfileCycleError: If a cycle is detected.
|
||||||
|
ConfigProfileNotFoundError: If the profile is not found.
|
||||||
|
"""
|
||||||
|
if _detect_cycle(profile_id, visited, path):
|
||||||
|
cycle_path = " -> ".join(str(p) for p in path + [profile_id])
|
||||||
|
raise ConfigProfileCycleError(
|
||||||
|
f"Cycle detected in profile includes: {cycle_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = await session.get(ConfigProfile, profile_id)
|
||||||
|
if profile is None:
|
||||||
|
raise ConfigProfileNotFoundError(f"Config profile not found: {profile_id}")
|
||||||
|
|
||||||
|
new_visited = visited | {profile_id}
|
||||||
|
new_path = path + [profile_id]
|
||||||
|
|
||||||
|
result = ResolvedProfile(
|
||||||
|
profile_id=profile.id,
|
||||||
|
profile_name=profile.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve includes in order
|
||||||
|
include_query = (
|
||||||
|
select(ConfigProfileInclude)
|
||||||
|
.where(ConfigProfileInclude.profile_id == profile_id)
|
||||||
|
.order_by(ConfigProfileInclude.order_index)
|
||||||
|
)
|
||||||
|
include_result = await session.execute(include_query)
|
||||||
|
includes = include_result.scalars().all()
|
||||||
|
|
||||||
|
for include in includes:
|
||||||
|
included = await _resolve_profile_recursive(
|
||||||
|
session, include.included_profile_id, new_visited, new_path
|
||||||
|
)
|
||||||
|
result.included_profiles.append(
|
||||||
|
{
|
||||||
|
"id": str(included.profile_id),
|
||||||
|
"name": included.profile_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result.env_vars = _merge_env_vars(
|
||||||
|
result.env_vars,
|
||||||
|
included.env_vars,
|
||||||
|
result.env_overrides,
|
||||||
|
included.profile_name,
|
||||||
|
)
|
||||||
|
result.runtime_hints = _merge_runtime_hints(
|
||||||
|
result.runtime_hints,
|
||||||
|
included.runtime_hints,
|
||||||
|
result.hint_overrides,
|
||||||
|
included.profile_name,
|
||||||
|
)
|
||||||
|
result.files = _merge_files(
|
||||||
|
result.files, included.files, result.file_overrides, included.profile_name
|
||||||
|
)
|
||||||
|
result.mounts = _merge_mounts(
|
||||||
|
result.mounts,
|
||||||
|
[
|
||||||
|
{"target": m.target, "mode": m.mode, "files": m.files}
|
||||||
|
for m in included.mounts.values()
|
||||||
|
],
|
||||||
|
result.mount_overrides,
|
||||||
|
included.profile_name,
|
||||||
|
)
|
||||||
|
result.git_mounts = _merge_git_mounts(
|
||||||
|
result.git_mounts, included.git_mounts, included.profile_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply the profile's own settings (selected profile overrides includes)
|
||||||
|
result.env_vars = _merge_env_vars(
|
||||||
|
result.env_vars,
|
||||||
|
profile.env_vars or {},
|
||||||
|
result.env_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.runtime_hints = _merge_runtime_hints(
|
||||||
|
result.runtime_hints,
|
||||||
|
profile.runtime_hints or {},
|
||||||
|
result.hint_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.files = _merge_files(
|
||||||
|
result.files,
|
||||||
|
profile.files or {},
|
||||||
|
result.file_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.mounts = _merge_mounts(
|
||||||
|
result.mounts,
|
||||||
|
profile.mounts or [],
|
||||||
|
result.mount_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.git_mounts = _merge_git_mounts(
|
||||||
|
result.git_mounts,
|
||||||
|
profile.git_mounts or [],
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_profile(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
) -> ResolvedProfile:
|
||||||
|
"""Resolve a config profile with all includes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
profile_id: Profile ID to resolve.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolvedProfile with merged env vars, runtime hints, mounts, and files.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigProfileCycleError: If a cycle is detected in includes.
|
||||||
|
ConfigProfileNotFoundError: If the profile is not found.
|
||||||
|
"""
|
||||||
|
return await _resolve_profile_recursive(session, profile_id, set(), [])
|
||||||
|
|
||||||
|
|
||||||
|
async def check_include_cycle(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
new_include_id: uuid.UUID | None = None,
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
"""Check if adding an include would create a cycle.
|
||||||
|
|
||||||
|
Used at save time to validate include relationships before persisting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
profile_id: The profile that would receive the new include.
|
||||||
|
new_include_id: Optional new profile to include. If None, checks existing includes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The cycle path as a list of UUIDs if a cycle exists, otherwise None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _check_from(
|
||||||
|
current_id: uuid.UUID,
|
||||||
|
target_id: uuid.UUID,
|
||||||
|
visited: set[uuid.UUID],
|
||||||
|
path: list[uuid.UUID],
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
if current_id in visited:
|
||||||
|
if current_id == target_id:
|
||||||
|
return path + [current_id]
|
||||||
|
return None
|
||||||
|
if current_id == target_id and path:
|
||||||
|
return path + [current_id]
|
||||||
|
|
||||||
|
new_visited = visited | {current_id}
|
||||||
|
new_path = path + [current_id]
|
||||||
|
|
||||||
|
include_query = (
|
||||||
|
select(ConfigProfileInclude)
|
||||||
|
.where(ConfigProfileInclude.profile_id == current_id)
|
||||||
|
.order_by(ConfigProfileInclude.order_index)
|
||||||
|
)
|
||||||
|
include_result = await session.execute(include_query)
|
||||||
|
includes = include_result.scalars().all()
|
||||||
|
|
||||||
|
for include in includes:
|
||||||
|
cycle = await _check_from(
|
||||||
|
include.included_profile_id, target_id, new_visited, new_path
|
||||||
|
)
|
||||||
|
if cycle is not None:
|
||||||
|
return cycle
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if new_include_id can reach profile_id (would create cycle)
|
||||||
|
if new_include_id is not None:
|
||||||
|
cycle = await _check_from(new_include_id, profile_id, set(), [])
|
||||||
|
if cycle is not None:
|
||||||
|
return cycle
|
||||||
|
|
||||||
|
# Also check existing includes for cycles
|
||||||
|
cycle = await _check_from(profile_id, profile_id, set(), [])
|
||||||
|
if cycle is not None and len(cycle) > 1:
|
||||||
|
return cycle
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_resolved_profile(
|
||||||
|
instance_dir: str,
|
||||||
|
resolved: ResolvedProfile,
|
||||||
|
home_dir: str = "/root",
|
||||||
|
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
||||||
|
"""Apply a resolved profile to an instance directory.
|
||||||
|
|
||||||
|
Stages files, writes env vars, and prepares mount volumes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to the instance directory.
|
||||||
|
resolved: The resolved profile.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (env_vars, files, volume_mounts, runtime_hints).
|
||||||
|
env_vars: Merged environment variables.
|
||||||
|
files: Relative file paths to content for the instance.
|
||||||
|
volume_mounts: List of Docker volume mount dicts.
|
||||||
|
runtime_hints: Extracted runtime hints.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
instance_path = Path(instance_dir)
|
||||||
|
env_vars = dict(resolved.env_vars)
|
||||||
|
files = dict(resolved.files)
|
||||||
|
volume_mounts = []
|
||||||
|
|
||||||
|
# Write profile files to instance directory
|
||||||
|
for file_path, content in files.items():
|
||||||
|
full_path = instance_path / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(instance_path.resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"Profile file path escapes instance directory: %s", file_path
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
# Stage mount files and prepare volume mounts
|
||||||
|
for mount in resolved.mounts.values():
|
||||||
|
expanded_target = expand_container_path(mount.target, home_dir)
|
||||||
|
mount_dir = (
|
||||||
|
instance_path / "mounts" / expanded_target.lstrip("/").replace("/", "_")
|
||||||
|
)
|
||||||
|
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for file_path, content in mount.files.items():
|
||||||
|
full_path = mount_dir / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(mount_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Mount file path escapes mount directory: %s", file_path)
|
||||||
|
continue
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
# Mount each file individually so sibling files from other mounts
|
||||||
|
# (e.g. git repo directories) are preserved.
|
||||||
|
file_target = os.path.join(expanded_target, file_path)
|
||||||
|
volume_mounts.append(
|
||||||
|
{
|
||||||
|
"source": str(full_path),
|
||||||
|
"target": file_target,
|
||||||
|
"type": "bind",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return env_vars, files, volume_mounts, resolved.runtime_hints
|
||||||
|
|
||||||
|
|
||||||
|
def expand_container_path(path: str, home_dir: str) -> str:
|
||||||
|
"""Expand ~ and $HOME in a container path to the actual home directory.
|
||||||
|
|
||||||
|
Only expands at the start of the path (e.g., ~/foo, $HOME/foo, $HOME).
|
||||||
|
Leaves mid-string occurrences unchanged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Container path that may contain ~ or $HOME.
|
||||||
|
home_dir: The container's home directory (e.g., /home/user or /root).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path with ~ and $HOME expanded.
|
||||||
|
"""
|
||||||
|
if path.startswith("~/"):
|
||||||
|
return os.path.join(home_dir, path[2:])
|
||||||
|
if path == "~":
|
||||||
|
return home_dir
|
||||||
|
if path.startswith("$HOME/"):
|
||||||
|
return home_dir + "/" + path[6:]
|
||||||
|
if path == "$HOME":
|
||||||
|
return home_dir
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
||||||
|
"""Convert a ResolvedProfile to a plain dict for serialization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resolved: The resolved profile.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with env_vars, runtime_hints, mounts, files, and metadata.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"profile_id": str(resolved.profile_id),
|
||||||
|
"profile_name": resolved.profile_name,
|
||||||
|
"env_vars": resolved.env_vars,
|
||||||
|
"runtime_hints": resolved.runtime_hints,
|
||||||
|
"mounts": [
|
||||||
|
{
|
||||||
|
"target": m.target,
|
||||||
|
"mode": m.mode,
|
||||||
|
"files": m.files,
|
||||||
|
"overridden_files": m.overridden_files,
|
||||||
|
}
|
||||||
|
for m in resolved.mounts.values()
|
||||||
|
],
|
||||||
|
"files": resolved.files,
|
||||||
|
"overrides": {
|
||||||
|
"env_vars": resolved.env_overrides,
|
||||||
|
"runtime_hints": resolved.hint_overrides,
|
||||||
|
"files": resolved.file_overrides,
|
||||||
|
"mounts": resolved.mount_overrides,
|
||||||
|
},
|
||||||
|
"git_mounts": resolved.git_mounts,
|
||||||
|
"included_profiles": resolved.included_profiles,
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Async correlation ID context variable and helpers."""
|
||||||
|
|
||||||
|
import contextvars
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
CORRELATION_ID: contextvars.ContextVar[str] = contextvars.ContextVar("correlation_id")
|
||||||
|
|
||||||
|
|
||||||
|
def get_correlation_id() -> str:
|
||||||
|
"""Return the current correlation ID or generate a new UUID."""
|
||||||
|
try:
|
||||||
|
return CORRELATION_ID.get()
|
||||||
|
except LookupError:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class CorrelationIdMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Set correlation ID from X-Request-ID header or generate a new UUID."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
request_id = request.headers.get("X-Request-ID")
|
||||||
|
correlation_id = request_id or str(uuid.uuid4())
|
||||||
|
token = CORRELATION_ID.set(correlation_id)
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-ID"] = correlation_id
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
CORRELATION_ID.reset(token)
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
"""Docker service for managing tool instances."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
|
||||||
|
"""Sort volume strings so parent paths come before child paths.
|
||||||
|
|
||||||
|
Docker Compose mounts volumes in array order. A later mount at a parent
|
||||||
|
path hides earlier mounts at child paths. By sorting shallow paths first
|
||||||
|
and deep paths last, deeper (more specific) mounts overlay correctly.
|
||||||
|
|
||||||
|
Volume format: source:target or source:target:type
|
||||||
|
|
||||||
|
Args:
|
||||||
|
volumes: List of Docker volume mount strings.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted list with parent paths before child paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _target_depth(vol: str) -> int:
|
||||||
|
parts = vol.split(":")
|
||||||
|
if len(parts) < 2:
|
||||||
|
return 0
|
||||||
|
target = parts[1].rstrip("/")
|
||||||
|
if not target or target == "/":
|
||||||
|
return 0
|
||||||
|
return target.count("/")
|
||||||
|
|
||||||
|
# Detect duplicate targets and warn
|
||||||
|
targets = []
|
||||||
|
for vol in volumes:
|
||||||
|
parts = vol.split(":")
|
||||||
|
targets.append(parts[1] if len(parts) > 1 else "")
|
||||||
|
dupes = [t for t, c in Counter(targets).items() if c > 1]
|
||||||
|
if dupes:
|
||||||
|
logger.warning("Duplicate mount targets detected: %s", dupes)
|
||||||
|
|
||||||
|
# Stable sort: parent paths first, child paths last
|
||||||
|
return sorted(volumes, key=_target_depth)
|
||||||
|
|
||||||
|
|
||||||
|
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||||
|
"""Render a Docker Compose template with variable substitution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: The compose template string
|
||||||
|
variables: Dictionary of variable names to values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered compose file content
|
||||||
|
"""
|
||||||
|
result = template
|
||||||
|
for key, value in variables.items():
|
||||||
|
placeholder = f"{{{{{key}}}}}"
|
||||||
|
result = result.replace(placeholder, str(value))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
|
||||||
|
"""Create and return the instance directory path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: Unique instance identifier
|
||||||
|
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to instance directory
|
||||||
|
"""
|
||||||
|
if base_path is None:
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
base_path = Settings().instance_base_path
|
||||||
|
instance_dir = Path(base_path) / instance_id
|
||||||
|
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(instance_dir.absolute())
|
||||||
|
|
||||||
|
|
||||||
|
def write_compose_file(instance_dir: str, content: str) -> str:
|
||||||
|
"""Write the rendered compose file to the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
content: Rendered compose content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the compose file
|
||||||
|
"""
|
||||||
|
compose_path = Path(instance_dir) / "docker-compose.yml"
|
||||||
|
compose_path.write_text(content)
|
||||||
|
return str(compose_path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
||||||
|
"""Write environment variables to a .env file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
env_vars: Dictionary of env var names to values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the env file
|
||||||
|
"""
|
||||||
|
env_path = Path(instance_dir) / ".env"
|
||||||
|
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
||||||
|
env_path.write_text("\n".join(lines) + "\n")
|
||||||
|
return str(env_path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||||
|
"""Write config files to the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
files: Dictionary of file paths (relative to instance dir) to content
|
||||||
|
"""
|
||||||
|
instance_path = Path(instance_dir)
|
||||||
|
for file_path, content in files.items():
|
||||||
|
# Ensure the path is within the instance directory (security)
|
||||||
|
full_path = instance_path / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(instance_path.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||||
|
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_compose_command(
|
||||||
|
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
|
"""Execute a docker compose command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
compose_path: Path to docker-compose.yml
|
||||||
|
action: The compose action (up, down, start, stop, restart)
|
||||||
|
timeout: Command timeout in seconds
|
||||||
|
env_file: Optional path to .env file for environment variables
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (returncode, stdout, stderr)
|
||||||
|
"""
|
||||||
|
instance_dir = Path(compose_path).parent
|
||||||
|
|
||||||
|
cmd = ["docker", "compose", "-f", compose_path]
|
||||||
|
|
||||||
|
if env_file:
|
||||||
|
cmd.extend(["--env-file", env_file])
|
||||||
|
|
||||||
|
if action == "up":
|
||||||
|
cmd.extend(["up", "-d", "--force-recreate"])
|
||||||
|
elif action == "down":
|
||||||
|
cmd.extend(["down", "-v"])
|
||||||
|
elif action in ("start", "stop", "restart"):
|
||||||
|
cmd.append(action)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown compose action: {action}")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(instance_dir),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_id(instance_name: str) -> str | None:
|
||||||
|
"""Get the container ID for a compose service.
|
||||||
|
|
||||||
|
Uses exact name matching to avoid substring collisions with tunnel
|
||||||
|
containers (e.g. tunnel-code-server-... matching code-server-...).
|
||||||
|
Falls back to case-insensitive matching since Docker DNS is case-
|
||||||
|
insensitive but docker inspect is case-sensitive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_name: The expected container name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container ID or None if not found.
|
||||||
|
"""
|
||||||
|
expected = instance_name.lower()
|
||||||
|
|
||||||
|
# Fast path: exact match via docker inspect
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "inspect", "-f", "{{.Id}}", expected],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
# Fallback: list all containers and do case-insensitive exact match
|
||||||
|
ps_result = subprocess.run(
|
||||||
|
["docker", "ps", "-a", "--format", "{{.Names}}\t{{.ID}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if ps_result.returncode == 0:
|
||||||
|
for line in ps_result.stdout.strip().splitlines():
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) == 2:
|
||||||
|
name, cid = parts
|
||||||
|
if name.lower() == expected:
|
||||||
|
return cid
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_name(instance_name: str) -> str | None:
|
||||||
|
"""Get the full container name for a compose service.
|
||||||
|
|
||||||
|
Uses exact name matching via docker inspect to avoid substring collisions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_name: The exact container name (case-insensitive for Docker).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container name or None if not found.
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "inspect", "-f", "{{.Name}}", instance_name.lower()],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip().lstrip("/")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_backend_network_name() -> str:
|
||||||
|
"""Auto-detect the actual Docker network name for the backend network.
|
||||||
|
|
||||||
|
Docker Compose prefixes network names with the project directory name
|
||||||
|
(e.g. 'headquarter_backend' instead of 'backend'). We inspect the API
|
||||||
|
container itself to find the real network name it's connected to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The actual Docker network name, or 'backend' as fallback.
|
||||||
|
"""
|
||||||
|
# Try to find the API container by its known name
|
||||||
|
api_container = "hq-api"
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
|
"{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}",
|
||||||
|
api_container,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
networks = result.stdout.strip().split()
|
||||||
|
for net in networks:
|
||||||
|
if "backend" in net.lower():
|
||||||
|
return net
|
||||||
|
# API container is on some network — return the first one
|
||||||
|
return networks[0]
|
||||||
|
return "backend"
|
||||||
|
|
||||||
|
|
||||||
|
def connect_container_to_network(
|
||||||
|
container_name: str, network_name: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Connect a Docker container to an existing network.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name or ID of the container
|
||||||
|
network_name: Name of the Docker network. If None, auto-detects
|
||||||
|
from the API container's own network membership.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
if network_name is None:
|
||||||
|
network_name = get_backend_network_name()
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "network", "connect", network_name, container_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_ip_on_network(
|
||||||
|
container_id: str, network_name: str | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""Get a container's IP address on a specific Docker network.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
network_name: Network name. If None, auto-detects from the API container.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IP address string, or None if the container is not on that network.
|
||||||
|
"""
|
||||||
|
if network_name is None:
|
||||||
|
network_name = get_backend_network_name()
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
|
f"{{{{.NetworkSettings.Networks.{network_name}.IPAddress}}}}",
|
||||||
|
container_id,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
ip = result.stdout.strip()
|
||||||
|
if ip and ip != "<no value>":
|
||||||
|
return ip
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_container_on_network(container_id: str, network_name: str | None = None) -> bool:
|
||||||
|
"""Check whether a container is already attached to a Docker network.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
network_name: Network name. If None, auto-detects from the API container.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the container is on the network.
|
||||||
|
"""
|
||||||
|
if network_name is None:
|
||||||
|
network_name = get_backend_network_name()
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
|
f"{{{{.NetworkSettings.Networks.{network_name}}}}}",
|
||||||
|
container_id,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.returncode == 0 and "<no value>" not in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_status(container_id: str) -> dict[str, Any]:
|
||||||
|
"""Get the status of a Docker container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'status' (running, exited, restarting, not_found),
|
||||||
|
'exit_code' (int or None), and 'health' (health status or None)
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
|
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||||
|
container_id,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {"status": "not_found", "exit_code": None, "health": None}
|
||||||
|
|
||||||
|
parts = result.stdout.strip().split("|")
|
||||||
|
status = parts[0] if parts else "unknown"
|
||||||
|
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||||
|
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
|
||||||
|
|
||||||
|
return {"status": status, "exit_code": exit_code, "health": health}
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_container_running(
|
||||||
|
container_id: str, timeout: int = 30, interval: float = 2.0
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Wait for a container to reach the running state.
|
||||||
|
|
||||||
|
Polls docker inspect until the container status is "running" or timeout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID
|
||||||
|
timeout: Maximum seconds to wait
|
||||||
|
interval: Seconds between polls
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||||
|
and 'waited_seconds' (float)
|
||||||
|
"""
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
info = get_container_status(container_id)
|
||||||
|
|
||||||
|
if info["status"] == "running":
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status": "running",
|
||||||
|
"exit_code": None,
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
if info["status"] == "exited":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": "exited",
|
||||||
|
"exit_code": info["exit_code"],
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
if info["status"] == "not_found":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": "not_found",
|
||||||
|
"exit_code": None,
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
# Timeout reached
|
||||||
|
info = get_container_status(container_id)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": info["status"],
|
||||||
|
"exit_code": info["exit_code"],
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||||
|
"""Get the logs of a Docker container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID
|
||||||
|
tail: Number of lines to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container logs
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "logs", "--tail", str(tail), container_id],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout
|
||||||
|
return f"Failed to get logs: {result.stderr}"
|
||||||
|
|
||||||
|
|
||||||
|
def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
||||||
|
"""Find a free TCP port in the given range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start: Start of port range
|
||||||
|
end: End of port range
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Free port number
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
for port in range(start, end):
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
if s.connect_ex(("localhost", port)) != 0:
|
||||||
|
return port
|
||||||
|
|
||||||
|
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Docker build service for building images from Dockerfiles."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_image(
|
||||||
|
instance_dir: str, dockerfile: str, tag: str, build_context: dict | None = None
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
|
"""Build a Docker image from a Dockerfile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Directory containing the Dockerfile
|
||||||
|
dockerfile: Dockerfile content
|
||||||
|
tag: Image tag to apply
|
||||||
|
build_context: Optional build context files {path: content}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (returncode, stdout, stderr)
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Defensive: normalise any CRLF that may have crept in from manifest DB
|
||||||
|
# strings — Docker's legacy builder treats \r as a character after the
|
||||||
|
# backslash, breaking RUN continuations and producing
|
||||||
|
# "unknown instruction" errors.
|
||||||
|
dockerfile = dockerfile.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
|
||||||
|
# Write Dockerfile
|
||||||
|
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
||||||
|
dockerfile_path.write_text(dockerfile, newline="\n")
|
||||||
|
logger.debug("Wrote Dockerfile to %s (%d bytes)", dockerfile_path, len(dockerfile))
|
||||||
|
|
||||||
|
# Write build context files
|
||||||
|
if build_context:
|
||||||
|
for file_path, content in build_context.items():
|
||||||
|
full_path = Path(instance_dir) / file_path
|
||||||
|
# Security: ensure path doesn't escape instance_dir
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(Path(instance_dir).resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.error(
|
||||||
|
"Build context file path escapes instance directory: %s", file_path
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
f"Build context file path '{file_path}' escapes instance directory"
|
||||||
|
)
|
||||||
|
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
full_path.write_text(normalized, newline="\n")
|
||||||
|
logger.debug("Wrote build context file: %s", full_path)
|
||||||
|
|
||||||
|
# Build image
|
||||||
|
logger.debug("Building Docker image with tag: %s", tag)
|
||||||
|
cmd = [
|
||||||
|
"docker",
|
||||||
|
"build",
|
||||||
|
"-t",
|
||||||
|
tag,
|
||||||
|
"-f",
|
||||||
|
str(dockerfile_path),
|
||||||
|
instance_dir,
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300, # 5 minute timeout for builds
|
||||||
|
)
|
||||||
|
logger.debug("Docker build completed: returncode=%d", result.returncode)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Docker build failed: %s", result.stderr[:1000])
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Docker build timed out after 300 seconds")
|
||||||
|
return 1, "", "Build timed out after 300 seconds"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Docker build failed: %s", exc)
|
||||||
|
return 1, "", str(exc)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""In-memory typed event bus for instance lifecycle and health events."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
InstanceEventPayload = dict[str, Any]
|
||||||
|
EventCallback = Callable[[InstanceEventPayload], Awaitable[None] | None] # noqa: UP044
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceEventBus:
|
||||||
|
"""Singleton in-memory event bus with typed pub/sub and exception isolation."""
|
||||||
|
|
||||||
|
_instance: "InstanceEventBus | None" = None
|
||||||
|
_lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._subscribers: dict[str, list[tuple[str, EventCallback]]] = {}
|
||||||
|
|
||||||
|
def __new__(cls) -> "InstanceEventBus":
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._subscribers = {}
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def _reset_for_testing(self) -> None:
|
||||||
|
"""Clear all subscribers. For test use only."""
|
||||||
|
self._subscribers.clear()
|
||||||
|
|
||||||
|
def subscribe(
|
||||||
|
self,
|
||||||
|
event_type: str,
|
||||||
|
callback: EventCallback,
|
||||||
|
) -> Callable[[], None]:
|
||||||
|
"""Register a callback for an event type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_type: The event type to subscribe to.
|
||||||
|
callback: A sync or async callable that receives the payload.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An unsubscribe function.
|
||||||
|
"""
|
||||||
|
if event_type not in self._subscribers:
|
||||||
|
self._subscribers[event_type] = []
|
||||||
|
callback_id = str(uuid.uuid4())
|
||||||
|
self._subscribers[event_type].append((callback_id, callback))
|
||||||
|
|
||||||
|
def unsubscribe() -> None:
|
||||||
|
self.unsubscribe(event_type, callback_id)
|
||||||
|
|
||||||
|
return unsubscribe
|
||||||
|
|
||||||
|
def unsubscribe(self, event_type: str, callback_id: str) -> None:
|
||||||
|
"""Remove a specific callback by ID."""
|
||||||
|
if event_type in self._subscribers:
|
||||||
|
self._subscribers[event_type] = [
|
||||||
|
(cid, cb)
|
||||||
|
for cid, cb in self._subscribers[event_type]
|
||||||
|
if cid != callback_id
|
||||||
|
]
|
||||||
|
if not self._subscribers[event_type]:
|
||||||
|
del self._subscribers[event_type]
|
||||||
|
|
||||||
|
def unsubscribe_all(self, event_type: str) -> None:
|
||||||
|
"""Remove all subscribers for an event type."""
|
||||||
|
self._subscribers.pop(event_type, None)
|
||||||
|
|
||||||
|
async def publish(self, event_type: str, payload: InstanceEventPayload) -> None:
|
||||||
|
"""Deliver payload to all subscribers of event_type.
|
||||||
|
|
||||||
|
Also delivers to subscribers registered under the wildcard "*".
|
||||||
|
Exceptions from individual subscribers are caught and logged;
|
||||||
|
delivery continues to remaining subscribers.
|
||||||
|
"""
|
||||||
|
callbacks: list[tuple[str, EventCallback]] = []
|
||||||
|
callbacks.extend(self._subscribers.get(event_type, []))
|
||||||
|
callbacks.extend(self._subscribers.get("*", []))
|
||||||
|
|
||||||
|
for _callback_id, callback in callbacks:
|
||||||
|
try:
|
||||||
|
if inspect.iscoroutinefunction(callback):
|
||||||
|
await callback(payload)
|
||||||
|
else:
|
||||||
|
callback(payload)
|
||||||
|
except Exception:
|
||||||
|
correlation_id = payload.get("correlation_id", "unknown")
|
||||||
|
logger.exception(
|
||||||
|
"Event subscriber failed for %s",
|
||||||
|
event_type,
|
||||||
|
extra={"correlation_id": correlation_id},
|
||||||
|
)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""File operations scoped to a workspace directory."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileEntry:
|
||||||
|
"""A single file or directory entry."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
type: str # "file" or "directory"
|
||||||
|
size: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileService:
|
||||||
|
"""Read and write files within a workspace directory."""
|
||||||
|
|
||||||
|
def list_directory(
|
||||||
|
self,
|
||||||
|
workspace: Workspace,
|
||||||
|
relative_path: str = "",
|
||||||
|
) -> list[FileEntry]:
|
||||||
|
"""List entries in a workspace directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace: The workspace to list files in.
|
||||||
|
relative_path: Path relative to workspace root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of file entries sorted by name (directories first).
|
||||||
|
"""
|
||||||
|
abs_path = os.path.join(workspace.path, relative_path)
|
||||||
|
abs_path = os.path.normpath(abs_path)
|
||||||
|
|
||||||
|
# Security: ensure we stay within workspace
|
||||||
|
if not abs_path.startswith(os.path.normpath(workspace.path)):
|
||||||
|
raise ValueError("Path escapes workspace directory")
|
||||||
|
|
||||||
|
if not os.path.exists(abs_path):
|
||||||
|
return []
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for item in sorted(os.listdir(abs_path)):
|
||||||
|
full = os.path.join(abs_path, item)
|
||||||
|
rel = os.path.join(relative_path, item) if relative_path else item
|
||||||
|
is_dir = os.path.isdir(full)
|
||||||
|
size = os.path.getsize(full) if os.path.isfile(full) else None
|
||||||
|
entries.append(
|
||||||
|
FileEntry(
|
||||||
|
name=item,
|
||||||
|
path=rel.replace("\\", "/"),
|
||||||
|
type="directory" if is_dir else "file",
|
||||||
|
size=size,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Directories first, then files, both alphabetical
|
||||||
|
entries.sort(key=lambda e: (0 if e.type == "directory" else 1, e.name.lower()))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def read_file(self, workspace: Workspace, relative_path: str) -> str:
|
||||||
|
"""Read a text file from the workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace: The workspace to read from.
|
||||||
|
relative_path: Path relative to workspace root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File contents as string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path escapes workspace or file is binary.
|
||||||
|
FileNotFoundError: If file does not exist.
|
||||||
|
"""
|
||||||
|
abs_path = self._resolve_path(workspace, relative_path)
|
||||||
|
|
||||||
|
if not os.path.isfile(abs_path):
|
||||||
|
raise FileNotFoundError(f"Not a file: {relative_path}")
|
||||||
|
|
||||||
|
# Basic binary check — read first 8KB and look for null bytes
|
||||||
|
with open(abs_path, "rb") as f:
|
||||||
|
chunk = f.read(8192)
|
||||||
|
if b"\x00" in chunk:
|
||||||
|
raise ValueError("Binary files cannot be viewed")
|
||||||
|
|
||||||
|
with open(abs_path, encoding="utf-8", errors="replace") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def write_file(
|
||||||
|
self,
|
||||||
|
workspace: Workspace,
|
||||||
|
relative_path: str,
|
||||||
|
content: str,
|
||||||
|
) -> None:
|
||||||
|
"""Write a text file to the workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace: The workspace to write to.
|
||||||
|
relative_path: Path relative to workspace root.
|
||||||
|
content: File contents.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path escapes workspace.
|
||||||
|
"""
|
||||||
|
abs_path = self._resolve_path(workspace, relative_path)
|
||||||
|
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||||
|
|
||||||
|
with open(abs_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
logger.info("Wrote file %s in workspace %s", relative_path, workspace.id)
|
||||||
|
|
||||||
|
def _resolve_path(self, workspace: Workspace, relative_path: str) -> str:
|
||||||
|
"""Resolve a relative path to absolute, with security check."""
|
||||||
|
abs_path = os.path.normpath(os.path.join(workspace.path, relative_path))
|
||||||
|
workspace_root = os.path.normpath(workspace.path)
|
||||||
|
|
||||||
|
if not abs_path.startswith(workspace_root):
|
||||||
|
raise ValueError("Path escapes workspace directory")
|
||||||
|
|
||||||
|
return abs_path
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Git commands scoped to a workspace directory."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GitStatus:
|
||||||
|
"""Parsed git status output."""
|
||||||
|
|
||||||
|
branch: str
|
||||||
|
modified: list[str]
|
||||||
|
added: list[str]
|
||||||
|
deleted: list[str]
|
||||||
|
untracked: list[str]
|
||||||
|
ahead: int = 0
|
||||||
|
behind: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Commit:
|
||||||
|
"""A single git commit."""
|
||||||
|
|
||||||
|
hash: str
|
||||||
|
message: str
|
||||||
|
author: str
|
||||||
|
date: str
|
||||||
|
|
||||||
|
|
||||||
|
class GitOperations:
|
||||||
|
"""Run git commands within a workspace directory."""
|
||||||
|
|
||||||
|
def __init__(self, workspace: Workspace) -> None:
|
||||||
|
self.cwd = workspace.path
|
||||||
|
self.branch = workspace.branch
|
||||||
|
|
||||||
|
async def _run(self, *cmd: str) -> tuple[int, str, str]:
|
||||||
|
"""Run a git command and return (returncode, stdout, stderr)."""
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
return proc.returncode or 0, stdout.decode(), stderr.decode()
|
||||||
|
|
||||||
|
async def status(self) -> GitStatus:
|
||||||
|
"""Get git status for the workspace."""
|
||||||
|
returncode, stdout, _ = await self._run(
|
||||||
|
"git", "-C", self.cwd, "status", "--porcelain", "-b"
|
||||||
|
)
|
||||||
|
|
||||||
|
modified: list[str] = []
|
||||||
|
added: list[str] = []
|
||||||
|
deleted: list[str] = []
|
||||||
|
untracked: list[str] = []
|
||||||
|
branch = self.branch
|
||||||
|
ahead = 0
|
||||||
|
behind = 0
|
||||||
|
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
if line.startswith("##"):
|
||||||
|
# Branch info line
|
||||||
|
branch_info = line[3:].strip()
|
||||||
|
if "..." in branch_info:
|
||||||
|
branch = branch_info.split("...")[0]
|
||||||
|
if "[ahead " in branch_info:
|
||||||
|
ahead_str = branch_info.split("[ahead ")[1].split("]")[0]
|
||||||
|
ahead = int(ahead_str.split(",")[0])
|
||||||
|
if "[behind " in branch_info:
|
||||||
|
behind_str = branch_info.split("[behind ")[1].split("]")[0]
|
||||||
|
behind = int(behind_str.split(",")[0])
|
||||||
|
else:
|
||||||
|
branch = branch_info
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(line) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
status_code = line[:2]
|
||||||
|
file_path = line[3:]
|
||||||
|
|
||||||
|
# XY format: X = index status, Y = working tree status
|
||||||
|
if status_code == "??":
|
||||||
|
untracked.append(file_path)
|
||||||
|
elif status_code[1] == "D" or status_code[0] == "D":
|
||||||
|
deleted.append(file_path)
|
||||||
|
elif status_code[0] == "A" or status_code[1] == "A":
|
||||||
|
added.append(file_path)
|
||||||
|
else:
|
||||||
|
modified.append(file_path)
|
||||||
|
|
||||||
|
return GitStatus(
|
||||||
|
branch=branch,
|
||||||
|
modified=modified,
|
||||||
|
added=added,
|
||||||
|
deleted=deleted,
|
||||||
|
untracked=untracked,
|
||||||
|
ahead=ahead,
|
||||||
|
behind=behind,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def commit(self, message: str) -> None:
|
||||||
|
"""Stage all changes and commit."""
|
||||||
|
rc, _, err = await self._run("git", "-C", self.cwd, "add", "-A")
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git add failed: {err}")
|
||||||
|
|
||||||
|
rc, _, err = await self._run("git", "-C", self.cwd, "commit", "-m", message)
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git commit failed: {err}")
|
||||||
|
|
||||||
|
logger.info("Committed in workspace: %s", self.cwd)
|
||||||
|
|
||||||
|
async def push(self) -> None:
|
||||||
|
"""Push current branch to origin."""
|
||||||
|
rc, _, err = await self._run(
|
||||||
|
"git", "-C", self.cwd, "push", "origin", self.branch
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git push failed: {err}")
|
||||||
|
|
||||||
|
logger.info("Pushed branch %s from workspace: %s", self.branch, self.cwd)
|
||||||
|
|
||||||
|
async def pull(self) -> None:
|
||||||
|
"""Pull current branch from origin."""
|
||||||
|
rc, _, err = await self._run(
|
||||||
|
"git", "-C", self.cwd, "pull", "origin", self.branch
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git pull failed: {err}")
|
||||||
|
|
||||||
|
logger.info("Pulled branch %s in workspace: %s", self.branch, self.cwd)
|
||||||
|
|
||||||
|
async def fetch(self) -> None:
|
||||||
|
"""Fetch from origin."""
|
||||||
|
rc, _, err = await self._run("git", "-C", self.cwd, "fetch", "origin")
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git fetch failed: {err}")
|
||||||
|
|
||||||
|
logger.info("Fetched origin for workspace: %s", self.cwd)
|
||||||
|
|
||||||
|
async def checkout(self, branch: str) -> None:
|
||||||
|
"""Checkout a branch."""
|
||||||
|
rc, _, err = await self._run("git", "-C", self.cwd, "checkout", branch)
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git checkout failed: {err}")
|
||||||
|
|
||||||
|
self.branch = branch
|
||||||
|
logger.info("Checked out branch %s in workspace: %s", branch, self.cwd)
|
||||||
|
|
||||||
|
async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]:
|
||||||
|
"""Get commit history.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Optional file path to filter history.
|
||||||
|
limit: Maximum number of commits.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of commits.
|
||||||
|
"""
|
||||||
|
cmd = [
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
self.cwd,
|
||||||
|
"log",
|
||||||
|
f"--max-count={limit}",
|
||||||
|
"--pretty=format:%H|%s|%an|%ad",
|
||||||
|
"--date=iso",
|
||||||
|
]
|
||||||
|
if path:
|
||||||
|
cmd.extend(["--", path])
|
||||||
|
|
||||||
|
rc, stdout, err = await self._run(*cmd)
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git log failed: {err}")
|
||||||
|
|
||||||
|
commits = []
|
||||||
|
for line in stdout.strip().splitlines():
|
||||||
|
parts = line.split("|", 3)
|
||||||
|
if len(parts) >= 4:
|
||||||
|
commits.append(
|
||||||
|
Commit(
|
||||||
|
hash=parts[0],
|
||||||
|
message=parts[1],
|
||||||
|
author=parts[2],
|
||||||
|
date=parts[3],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return commits
|
||||||
|
|
||||||
|
async def branches(self) -> tuple[list[str], str]:
|
||||||
|
"""List all branches and current branch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (all_branches, current_branch).
|
||||||
|
"""
|
||||||
|
rc, stdout, err = await self._run(
|
||||||
|
"git", "-C", self.cwd, "branch", "-a", "--format=%(refname:short)"
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git branch failed: {err}")
|
||||||
|
|
||||||
|
branches = []
|
||||||
|
current = self.branch
|
||||||
|
for line in stdout.strip().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("HEAD") or line.endswith("/HEAD"):
|
||||||
|
continue
|
||||||
|
if line.startswith("remotes/origin/"):
|
||||||
|
branch_name = line.replace("remotes/origin/", "")
|
||||||
|
if branch_name not in branches:
|
||||||
|
branches.append(branch_name)
|
||||||
|
elif line and line not in branches:
|
||||||
|
branches.append(line)
|
||||||
|
|
||||||
|
return branches, current
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user