Compare commits
442 Commits
main
..
b4c6a9eef7
| Author | SHA1 | Date | |
|---|---|---|---|
| b4c6a9eef7 | |||
| eff81fad02 | |||
| 46551988ae | |||
| 4201326467 | |||
| b6f89f9df0 | |||
| 51a399c775 | |||
| fc75eeb76d | |||
| 37134b8c18 | |||
| c6b804bf0a | |||
| c754984df8 | |||
| 906aab3b73 | |||
| e1aaf9f6fc | |||
| 6170306d9e | |||
| 04cd9ff472 | |||
| 1bf42a7feb | |||
| 56dd7d3fd3 | |||
| 8837031fd2 | |||
| a0cfbbc2d2 | |||
| 280a6ff2fa | |||
| 78e808bc54 | |||
| c1445976d7 | |||
| ff8aa2a4f5 | |||
| 398436ecb5 | |||
| 06a4a27880 | |||
| f34c733706 | |||
| 95efa5d029 | |||
| 9a036f1968 | |||
| ec6d4ad496 | |||
| d70b8e2363 | |||
| ee3c5af7a4 | |||
| b02cd978c3 | |||
| e956d7c30d | |||
| b8fc4e6642 | |||
| ab1843b1c3 | |||
| 88a973dc68 | |||
| 27c77af591 | |||
| e7587ca9f5 | |||
| 59b125d8e2 | |||
| b05de96569 | |||
| a5d64d1859 | |||
| 5bba2bbd92 | |||
| 986091ac56 | |||
| 47b1af8e92 | |||
| d567225bf7 | |||
| d2b6bba15c | |||
| b7396d58d2 | |||
| 351e76c00d | |||
| 2c2c4f3683 | |||
| fdf78353ad | |||
| 4cc433a1b8 | |||
| 321b4e3d0e | |||
| 2e156fc534 | |||
| cc52811522 | |||
| 9cab8c7bc7 | |||
| eeb7d9a1b2 | |||
| 6cf06d2380 | |||
| 401ad2e65d | |||
| 4814ec2363 | |||
| 98b9d612fa | |||
| 874873541d | |||
| ef9ac76f06 | |||
| ca9db195de | |||
| c1e16f2163 | |||
| 61d32fa00f | |||
| cddb3f8ccf | |||
| 87a938fe58 | |||
| 9157694412 | |||
| aa34314175 | |||
| c7fc386d0f | |||
| 6bd814e346 | |||
| aa25852091 | |||
| c2740cd282 | |||
| 23875bb3cc | |||
| ee1eab8408 | |||
| 2254ba7496 | |||
| 4866ad08b1 | |||
| 97ebc19313 | |||
| 946ac6f66a | |||
| 90ddee14c2 | |||
| f17f8ae8c8 | |||
| d713bfc5f9 | |||
| 27fe8c24ec | |||
| eef1e4e8c6 | |||
| a7a5905874 | |||
| 021537de56 | |||
| fdfd75790d | |||
| 3d1f8d9cf7 | |||
| eec37ab710 | |||
| 5f499ec1b0 | |||
| 2b5223097f | |||
| 1efbc289ba | |||
| 3c57c8b78b | |||
| 9c4500f9cb | |||
| 1e2c5a68cf | |||
| dc6991e6ef | |||
| cdf233378c | |||
| 23769e6ad4 | |||
| 9f8058223a | |||
| b483a34517 | |||
| a8fbca9ef5 | |||
| de8c47c81c | |||
| b11089896a | |||
| 16549709e2 | |||
| 68977b73be | |||
| 3da2bc93cb | |||
| d9632a3412 | |||
| 03d22c4d06 | |||
| 19242b4152 | |||
| ceaed9af66 | |||
| e9364fa70f | |||
| 2bec205a30 | |||
| cbd3436ff7 | |||
| 57ff236f2d | |||
| 6085859874 | |||
| d413fb84a5 | |||
| c22b047b8c | |||
| 090edf7ef6 | |||
| cbaebcf649 | |||
| 4a0d38384f | |||
| ea006b68c2 | |||
| 202533fbb1 | |||
| 0952aa8217 | |||
| 787e8844bc | |||
| fe98f966d6 | |||
| 79ad3b0715 | |||
| f728011b2a | |||
| 569876538a | |||
| d2b1c132d1 | |||
| 8926152fca | |||
| 2682e0268c | |||
| f13a63dc2f | |||
| 4a7f24348c | |||
| 0fdbef578f | |||
| 29a12bb102 | |||
| 270764ff0f | |||
| 0e6521e433 | |||
| e20d94d6ba | |||
| f4802ece4d | |||
| 9800e37cd6 | |||
| 84f30b07c4 | |||
| fba5e7c7be | |||
| 1e7bd0a540 | |||
| 0a0af4e02a | |||
| 3aa56dcfc3 | |||
| 7e3c701ea6 | |||
| e672bdde54 | |||
| c7c4cb45a7 | |||
| 6e4275a510 | |||
| 3ef60be623 | |||
| a3d01dd0a5 | |||
| 9bd5fc5c68 | |||
| b6e71e32f5 | |||
| 9ccaae04db | |||
| 9f90624aa6 | |||
| 62c1fb3836 | |||
| 569c20cf63 | |||
| f658b71079 | |||
| 3e99e7f197 | |||
| c2c983a01e | |||
| e46b4f9249 | |||
| 8eb851793d | |||
| 8e5e815ac9 | |||
| 143a254b0c | |||
| 5deee8c65c | |||
| 62d1bdc462 | |||
| 0b35ae3bf0 | |||
| b55300ff6f | |||
| 314ba3aee4 | |||
| 18e4a89573 | |||
| 29943ac239 | |||
| 22474cdba5 | |||
| 0c839e8c6f | |||
| c63cf7db50 | |||
| d9d2b91384 | |||
| d6ea5fb1fd | |||
| 1883825b18 | |||
| bc71fd6fac | |||
| 28aa9ccf5a | |||
| 44dd80cb58 | |||
| 23485833d8 | |||
| e23dcdf4e1 | |||
| f05ac55875 | |||
| bcefeb4163 | |||
| 33d08faf70 | |||
| 8a58c61278 | |||
| 8231e750d9 | |||
| 6ce645d210 | |||
| 89ca9f10c7 | |||
| baabd1fa62 | |||
| f14fc37e75 | |||
| e07938098a | |||
| 943b9db5c7 | |||
| a4604d6a9a | |||
| 18204628cc | |||
| 93b415c53e | |||
| e7adfb462b | |||
| ed1d6528c6 | |||
| c4be7163d6 | |||
| 13f55fff47 | |||
| 0ec20b9c23 | |||
| 4c11163bff | |||
| adda76a2ff | |||
| 47962ed476 | |||
| 6a0c9bd669 | |||
| 76fbf0a755 | |||
| 187193fa6e | |||
| cd9c9539a2 | |||
| 555517c144 | |||
| fc1554140f | |||
| bc5e80c954 | |||
| ab79080f0b | |||
| 4c216dd1ca | |||
| a37a3122f9 | |||
| a905cf729e | |||
| 3a16775188 | |||
| b363d89768 | |||
| 27c39f9cfc | |||
| e8d5b16acc | |||
| 437ad840ef | |||
| c2a232d8f0 | |||
| adaedb70ef | |||
| 5178cf9cbf | |||
| 01a0ef46c9 | |||
| a4c429d53a | |||
| 1fc244e818 | |||
| 8fb4b67372 | |||
| fbd41e3eb4 | |||
| 9c57a94e9f | |||
| 84b7b64ec0 | |||
| 29ed0f2a3b | |||
| 6c32e5266c | |||
| 9e88acaa36 | |||
| 0d10caf489 | |||
| 245d79569e | |||
| 8e86cd255c | |||
| 6ee667c384 | |||
| 21285498ae | |||
| 9751b65dce | |||
| 612217ad89 | |||
| b9ea806c0d | |||
| 22f736ce20 | |||
| deb22bec0f | |||
| 9320e175d1 | |||
| d8f220d825 | |||
| 77b7c82563 | |||
| 3fc392a314 | |||
| edd8882fa0 | |||
| 760369b102 | |||
| 8da527f964 | |||
| 76fbd74d20 | |||
| 384beeca8a | |||
| 869efda214 | |||
| 51b5d723ac | |||
| 33b482ce91 | |||
| 0cb2eefd29 | |||
| 5f5dc9c851 | |||
| 4864d269e8 | |||
| 29e4bed9e6 | |||
| c8da0ab6c4 | |||
| b04a458975 | |||
| 2299bd51ba | |||
| 45fb0c753c | |||
| c82e628e6f | |||
| 383a874bcf | |||
| 18634387c7 | |||
| 4b09f611d6 | |||
| 5c998f5bf9 | |||
| c595a513d5 | |||
| 08bd8bf7f9 | |||
| 1b7308d091 | |||
| 1060cb60ed | |||
| b58696bb7e | |||
| 4d0834069b | |||
| fc41b51bf0 | |||
| 3dbb6321fc | |||
| 7282b91d99 | |||
| 9ad11a021c | |||
| 9f29ac15da | |||
| 3092038e40 | |||
| 738e01bb7c | |||
| 4d0c70de98 | |||
| 701bd57293 | |||
| 85f04447ea | |||
| d931f3071d | |||
| 4a356fe88e | |||
| ca22e9c9d2 | |||
| 4de312c170 | |||
| e7a89a853f | |||
| 7b23618ae7 | |||
| 8b4e1a7428 | |||
| f37813a317 | |||
| 6e600fdbcd | |||
| f4211ad452 | |||
| 058c501e4a | |||
| 74033243c9 | |||
| 9910fd4445 | |||
| 4a66a4a384 | |||
| fa20d00d14 | |||
| f59274ae64 | |||
| b89fb608b6 | |||
| 116cd22ff8 | |||
| 0094ba01cd | |||
| f6fb984ec6 | |||
| 0dba13a354 | |||
| 5500552993 | |||
| fed49dba6d | |||
| fa17b13413 | |||
| 38185b9659 | |||
| 07e7c6ea0f | |||
| b638dccd36 | |||
| 8f7e19fdb1 | |||
| ba76f09a1c | |||
| 8b08c3886c | |||
| 5577e19782 | |||
| 268651bab0 | |||
| fc873e2d6b | |||
| 7389344b6d | |||
| 0a8f1419a6 | |||
| 09938bede4 | |||
| 073013bc61 | |||
| 865b9411da | |||
| 48d1a7d05c | |||
| ab1d3a6aa1 | |||
| d117047711 | |||
| d1c187ab16 | |||
| ecd3ba5918 | |||
| a919ff8611 | |||
| 39cf01c3c9 | |||
| c49bb028c4 | |||
| 0baf7f7750 | |||
| 6e496e102d | |||
| 7cb88a3163 | |||
| ce1a73abce | |||
| 5fddc65468 | |||
| f065e2b8c0 | |||
| f4cf286bb5 | |||
| 549d13f469 | |||
| 45cd192188 | |||
| 030a39dd5a | |||
| ed15d53493 | |||
| ffb7ef0d21 | |||
| e265c86997 | |||
| 23c876b558 | |||
| 74c65068c8 | |||
| 48277369f2 | |||
| 5f39267781 | |||
| caf11cdb8a | |||
| 2f44306089 | |||
| 6173d42ddf | |||
| 9afd559394 | |||
| 25da0c149a | |||
| 457f0d29ae | |||
| dfbd3e60a8 | |||
| 075eb6a76b | |||
| 4571bebf8e | |||
| ec45283257 | |||
| cce3fa773a | |||
| 50474f7b13 | |||
| 9c7043bab1 | |||
| 2dd2f2ab06 | |||
| e555561a2d | |||
| 4ac3d593aa | |||
| 1a3860a4d8 | |||
| 3d9ff44d1a | |||
| 12378def4d | |||
| f6f7853aa4 | |||
| 802d8f1e8c | |||
| 4f9aa7e3c2 | |||
| cb25b94cb7 | |||
| 01aaf4c78f | |||
| 96c8dd7402 | |||
| f5c2c95af0 | |||
| 06fe8623bc | |||
| f2fed518f0 | |||
| cc2a638c76 | |||
| 5cec4a7a6f | |||
| 7e0df57f8c | |||
| aea1ff95f6 | |||
| c8fdca7f60 | |||
| 014b88ee56 | |||
| b6bda3d692 | |||
| d7fb51f427 | |||
| 0d57e3501a | |||
| 3672312028 | |||
| c5f117e5b1 | |||
| 10a5c29702 | |||
| 312a646b89 | |||
| 01adc9a00f | |||
| 2e9ca52cdb | |||
| cd4eba9803 | |||
| 18646e3d1b | |||
| 8c5e1b931e | |||
| d1be2e4951 | |||
| 953ea05756 | |||
| 507b71c586 | |||
| aebcf25bf4 | |||
| ae42cac61e | |||
| e2ad7d7fb6 | |||
| 9e1334eb6d | |||
| c41993310b | |||
| cb25f21c44 | |||
| 392e85ead4 | |||
| a4bf8afac9 | |||
| a559470369 | |||
| 8cab17472e | |||
| e9d404b1ff | |||
| dab6c74046 | |||
| ca8b255148 | |||
| 02a2ad6df5 | |||
| 4ef0f108ea | |||
| dc8ef0e463 | |||
| 6a7657aeda | |||
| eca8b8815b | |||
| d0f7a97f92 | |||
| 765cb965e6 | |||
| 063a839790 | |||
| ae41a64e66 | |||
| 0901b1e832 | |||
| 7cc720786e | |||
| e167a6be12 | |||
| 0fa926284c | |||
| 5c17de0c3c | |||
| 8efadc4432 | |||
| 1e40540ef4 | |||
| 7cbbb41661 | |||
| 952a9f3234 | |||
| ab8872f79e | |||
| be4893e2a7 | |||
| b3c6a5fdc9 | |||
| b7d17cea78 | |||
| 36d6448f5f | |||
| 20a5f6a9a1 | |||
| 1c94583307 | |||
| 95a7454bee | |||
| 649496b762 | |||
| d13e16f5e1 | |||
| d5f9df33b7 | |||
| 468e0eacda | |||
| 2a9e57ad0d | |||
| e4c5e7f2db | |||
| 70957e462a | |||
| 684a11610a |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"fingerprint": "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
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -85,8 +90,40 @@ Before completion, report:
|
|||||||
|
|
||||||
Do not claim completion without verification evidence.
|
Do not claim completion without verification evidence.
|
||||||
|
|
||||||
|
## Git branch policy
|
||||||
|
|
||||||
|
- **Default working branch:** `dev` — all commits and pushes target `dev` unless the user explicitly requests otherwise.
|
||||||
|
- `main` is the stable/production branch; merge to `main` only when explicitly instructed.
|
||||||
|
- After committing, push to `origin/dev`.
|
||||||
|
- If `dev` does not exist locally, create it from `main` or fetch it from origin.
|
||||||
|
|
||||||
## 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:
|
||||||
|
|||||||
+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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+3
-2
@@ -27,6 +27,7 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
git \
|
git \
|
||||||
|
openssh-client \
|
||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
@@ -49,8 +50,8 @@ ENV PATH=/root/.local/bin:$PATH
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY --chown=appuser:appgroup . .
|
COPY --chown=appuser:appgroup . .
|
||||||
|
|
||||||
# Create directories for repo and instance storage
|
# Create directories for repo, instance, and workspace storage
|
||||||
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
|
RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data
|
||||||
|
|
||||||
# Copy wait-for-db script
|
# Copy wait-for-db script
|
||||||
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
||||||
|
|||||||
@@ -0,0 +1,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,23 @@
|
|||||||
|
"""merge_remove_is_builtin_and_add_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 6fc7bfcf199f
|
||||||
|
Revises: 2026_05_23_remove_is_builtin, 2026_05_24_add_config_profiles
|
||||||
|
Create Date: 2026-05-24 18:00:43.990361
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '6fc7bfcf199f'
|
||||||
|
down_revision = ('2026_05_23_remove_is_builtin', '2026_05_24_add_config_profiles')
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""merge profile resolver and workspaces heads
|
||||||
|
|
||||||
|
Revision ID: 86cec91fdb00
|
||||||
|
Revises: 0014_add_profile_resolver_fields, 2026_06_01_add_workspaces
|
||||||
|
Create Date: 2026-06-03 12:48:36.145702
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "86cec91fdb00"
|
||||||
|
down_revision = ("0014_add_profile_resolver_fields", "2026_06_01_add_workspaces")
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""merge_single_interface_and_clone_mode
|
||||||
|
|
||||||
|
Revision ID: f3d2dc90ba3a
|
||||||
|
Revises: 0015_single_interface, 2026_05_22_add_clone_mode
|
||||||
|
Create Date: 2026-05-24 10:43:14.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "f3d2dc90ba3a"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ("0015_single_interface", "2026_05_22_add_clone_mode")
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -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"]
|
||||||
|
|||||||
+10
-10
@@ -48,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")
|
||||||
@@ -63,7 +63,7 @@ async def callback(
|
|||||||
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)
|
||||||
@@ -71,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:
|
||||||
@@ -92,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")
|
||||||
@@ -100,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()
|
||||||
@@ -165,20 +165,20 @@ 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.info("Auth /me called, cookie present: %s", bool(session_cookie))
|
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")
|
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.info("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
logger.debug("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
||||||
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
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.info("Auth /me: decoded session for user_id=%s", 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)
|
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))
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
"""Config folder API endpoints."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from pydantic import Field
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
|
||||||
from src.models.config_folder import ConfigFolder
|
|
||||||
from src.schemas.config_folder import (
|
|
||||||
ConfigFolderCreate,
|
|
||||||
ConfigFolderUpdate,
|
|
||||||
ConfigFolderResponse,
|
|
||||||
ProjectOverrideCreate,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
|
|
||||||
async def list_config_folders(
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""List config folders for the current user."""
|
|
||||||
query = select(ConfigFolder).where(ConfigFolder.user_id == user_id)
|
|
||||||
result = await session.execute(query)
|
|
||||||
folders = result.scalars().all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"folders": [
|
|
||||||
{
|
|
||||||
"id": str(f.id),
|
|
||||||
"user_id": str(f.user_id),
|
|
||||||
"name": f.name,
|
|
||||||
"description": f.description,
|
|
||||||
"mount_path": f.mount_path,
|
|
||||||
"files": f.files,
|
|
||||||
"project_overrides": f.project_overrides,
|
|
||||||
"is_active": f.is_active,
|
|
||||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
|
||||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
|
||||||
}
|
|
||||||
for f in folders
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_config_folder(
|
|
||||||
data: ConfigFolderCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Create a config folder."""
|
|
||||||
# Check for duplicate name
|
|
||||||
existing = await session.scalar(
|
|
||||||
select(ConfigFolder).where(
|
|
||||||
ConfigFolder.user_id == user_id,
|
|
||||||
ConfigFolder.name == data.name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"config folder with name '{data.name}' already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
folder = ConfigFolder(
|
|
||||||
user_id=user_id,
|
|
||||||
name=data.name,
|
|
||||||
description=data.description,
|
|
||||||
mount_path=data.mount_path,
|
|
||||||
files=data.files,
|
|
||||||
)
|
|
||||||
session.add(folder)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"user_id": str(folder.user_id),
|
|
||||||
"name": folder.name,
|
|
||||||
"description": folder.description,
|
|
||||||
"mount_path": folder.mount_path,
|
|
||||||
"files": folder.files,
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
"is_active": folder.is_active,
|
|
||||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
|
||||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{folder_id}", summary="Update config folder", description="Update an existing config folder.")
|
|
||||||
async def update_config_folder(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
data: ConfigFolderUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Update a config folder."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
if data.name is not None:
|
|
||||||
folder.name = data.name
|
|
||||||
if data.description is not None:
|
|
||||||
folder.description = data.description
|
|
||||||
if data.mount_path is not None:
|
|
||||||
folder.mount_path = data.mount_path
|
|
||||||
if data.files is not None:
|
|
||||||
folder.files = data.files
|
|
||||||
if data.is_active is not None:
|
|
||||||
folder.is_active = data.is_active
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"user_id": str(folder.user_id),
|
|
||||||
"name": folder.name,
|
|
||||||
"description": folder.description,
|
|
||||||
"mount_path": folder.mount_path,
|
|
||||||
"files": folder.files,
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
"is_active": folder.is_active,
|
|
||||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
|
||||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_config_folder(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
"""Delete a config folder."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
await session.delete(folder)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectOverrideWithId(ProjectOverrideCreate):
|
|
||||||
project_id: uuid.UUID = Field(description="Project ID for the override")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.")
|
|
||||||
async def get_config_folder(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Get a config folder by ID."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"user_id": str(folder.user_id),
|
|
||||||
"name": folder.name,
|
|
||||||
"description": folder.description,
|
|
||||||
"mount_path": folder.mount_path,
|
|
||||||
"files": folder.files,
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
"is_active": folder.is_active,
|
|
||||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
|
||||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.")
|
|
||||||
async def add_project_override(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
data: ProjectOverrideWithId,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Add a project override to a config folder."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
# Initialize project_overrides if None
|
|
||||||
if folder.project_overrides is None:
|
|
||||||
folder.project_overrides = {}
|
|
||||||
|
|
||||||
# Add/update override
|
|
||||||
override_data = {}
|
|
||||||
if data.mount_path is not None:
|
|
||||||
override_data["mount_path"] = data.mount_path
|
|
||||||
if data.files is not None:
|
|
||||||
override_data["files"] = data.files
|
|
||||||
|
|
||||||
# Use a copy to trigger SQLAlchemy change detection on JSONB
|
|
||||||
current_overrides = dict(folder.project_overrides or {})
|
|
||||||
current_overrides[str(data.project_id)] = override_data
|
|
||||||
folder.project_overrides = current_overrides
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{folder_id}/overrides/{project_id}", summary="Update project override", description="Update a project override.")
|
|
||||||
async def update_project_override(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
data: ProjectOverrideCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Update a project override."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
# Initialize project_overrides if None
|
|
||||||
if folder.project_overrides is None:
|
|
||||||
folder.project_overrides = {}
|
|
||||||
|
|
||||||
# Update override
|
|
||||||
current_overrides = dict(folder.project_overrides or {})
|
|
||||||
override_data = current_overrides.get(str(project_id), {})
|
|
||||||
if data.mount_path is not None:
|
|
||||||
override_data["mount_path"] = data.mount_path
|
|
||||||
if data.files is not None:
|
|
||||||
override_data["files"] = data.files
|
|
||||||
|
|
||||||
current_overrides[str(project_id)] = override_data
|
|
||||||
folder.project_overrides = current_overrides
|
|
||||||
|
|
||||||
# Mark the field as modified to ensure SQLAlchemy detects the change
|
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
|
||||||
flag_modified(folder, "project_overrides")
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{folder_id}/overrides/{project_id}", summary="Remove project override", description="Remove a project override.")
|
|
||||||
async def remove_project_override(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
"""Remove a project override."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
# Remove override if exists
|
|
||||||
current_overrides = dict(folder.project_overrides or {})
|
|
||||||
if str(project_id) in current_overrides:
|
|
||||||
del current_overrides[str(project_id)]
|
|
||||||
folder.project_overrides = current_overrides
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"project_overrides": folder.project_overrides or {},
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
"""Tool configuration API endpoints."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
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_config import ToolConfig
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.schemas.tool_config import ToolConfigCreate, ToolConfigUpdate, ToolConfigResponse
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
|
|
||||||
async def list_configs(
|
|
||||||
tool_type_id: str | None = None,
|
|
||||||
project_id: str | None = None,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list:
|
|
||||||
"""List tool configs for the current user."""
|
|
||||||
query = select(ToolConfig).where(ToolConfig.user_id == user_id)
|
|
||||||
|
|
||||||
if tool_type_id:
|
|
||||||
query = query.where(ToolConfig.tool_type_id == uuid.UUID(tool_type_id))
|
|
||||||
if project_id:
|
|
||||||
query = query.where(ToolConfig.project_id == uuid.UUID(project_id))
|
|
||||||
else:
|
|
||||||
# If no project specified, get only global configs (project_id is None)
|
|
||||||
query = query.where(ToolConfig.project_id.is_(None))
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
configs = result.scalars().all()
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": str(c.id),
|
|
||||||
"tool_type_id": str(c.tool_type_id),
|
|
||||||
"project_id": str(c.project_id) if c.project_id else None,
|
|
||||||
"key": c.key,
|
|
||||||
"value": c.value,
|
|
||||||
"config_type": c.config_type,
|
|
||||||
"file_path": c.file_path,
|
|
||||||
"port_override": c.port_override,
|
|
||||||
"start_command": c.start_command,
|
|
||||||
"working_directory": c.working_directory,
|
|
||||||
"environment_variables": c.environment_variables,
|
|
||||||
"volumes": c.volumes,
|
|
||||||
}
|
|
||||||
for c in configs
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", summary="Create tool config", description="Create a new tool config.", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_config(
|
|
||||||
data: ToolConfigCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Create a tool config."""
|
|
||||||
# Verify tool type exists
|
|
||||||
tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id))
|
|
||||||
if tool_type is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
|
||||||
|
|
||||||
# Check for existing config with same key
|
|
||||||
query = select(ToolConfig).where(
|
|
||||||
ToolConfig.user_id == user_id,
|
|
||||||
ToolConfig.tool_type_id == uuid.UUID(data.tool_type_id),
|
|
||||||
ToolConfig.key == data.key,
|
|
||||||
)
|
|
||||||
if data.project_id:
|
|
||||||
query = query.where(ToolConfig.project_id == uuid.UUID(data.project_id))
|
|
||||||
else:
|
|
||||||
query = query.where(ToolConfig.project_id.is_(None))
|
|
||||||
|
|
||||||
existing = await session.scalar(query)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"config with key '{data.key}' already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
config = ToolConfig(
|
|
||||||
user_id=user_id,
|
|
||||||
tool_type_id=uuid.UUID(data.tool_type_id),
|
|
||||||
project_id=uuid.UUID(data.project_id) if data.project_id else None,
|
|
||||||
key=data.key,
|
|
||||||
value=data.value,
|
|
||||||
config_type=data.config_type,
|
|
||||||
file_path=data.file_path,
|
|
||||||
port_override=data.port_override,
|
|
||||||
start_command=data.start_command,
|
|
||||||
working_directory=data.working_directory,
|
|
||||||
environment_variables=data.environment_variables,
|
|
||||||
volumes=data.volumes,
|
|
||||||
)
|
|
||||||
session.add(config)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(config)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(config.id),
|
|
||||||
"tool_type_id": str(config.tool_type_id),
|
|
||||||
"project_id": str(config.project_id) if config.project_id else None,
|
|
||||||
"key": config.key,
|
|
||||||
"value": config.value,
|
|
||||||
"config_type": config.config_type,
|
|
||||||
"file_path": config.file_path,
|
|
||||||
"port_override": config.port_override,
|
|
||||||
"start_command": config.start_command,
|
|
||||||
"working_directory": config.working_directory,
|
|
||||||
"environment_variables": config.environment_variables,
|
|
||||||
"volumes": config.volumes,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.")
|
|
||||||
async def update_config(
|
|
||||||
config_id: uuid.UUID,
|
|
||||||
data: ToolConfigUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Update a tool config."""
|
|
||||||
config = await session.get(ToolConfig, config_id)
|
|
||||||
if config is None or config.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
|
||||||
|
|
||||||
if data.key is not None:
|
|
||||||
config.key = data.key
|
|
||||||
if data.value is not None:
|
|
||||||
config.value = data.value
|
|
||||||
if data.config_type is not None:
|
|
||||||
config.config_type = data.config_type
|
|
||||||
if data.file_path is not None:
|
|
||||||
config.file_path = data.file_path
|
|
||||||
if data.port_override is not None:
|
|
||||||
config.port_override = data.port_override
|
|
||||||
if data.start_command is not None:
|
|
||||||
config.start_command = data.start_command
|
|
||||||
if data.working_directory is not None:
|
|
||||||
config.working_directory = data.working_directory
|
|
||||||
if data.environment_variables is not None:
|
|
||||||
config.environment_variables = data.environment_variables
|
|
||||||
if data.volumes is not None:
|
|
||||||
config.volumes = data.volumes
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(config)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(config.id),
|
|
||||||
"tool_type_id": str(config.tool_type_id),
|
|
||||||
"project_id": str(config.project_id) if config.project_id else None,
|
|
||||||
"key": config.key,
|
|
||||||
"value": config.value,
|
|
||||||
"config_type": config.config_type,
|
|
||||||
"file_path": config.file_path,
|
|
||||||
"port_override": config.port_override,
|
|
||||||
"start_command": config.start_command,
|
|
||||||
"working_directory": config.working_directory,
|
|
||||||
"environment_variables": config.environment_variables,
|
|
||||||
"volumes": config.volumes,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/defaults/{tool_type_id}", summary="Get default configs", description="Get suggested default configs for a tool type.")
|
|
||||||
async def get_default_configs(
|
|
||||||
tool_type_id: str,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Get suggested default configs for a tool type."""
|
|
||||||
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
|
||||||
if tool_type is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
|
||||||
|
|
||||||
# Return suggested defaults based on required_variables
|
|
||||||
defaults = []
|
|
||||||
for var in tool_type.required_variables:
|
|
||||||
defaults.append({
|
|
||||||
"key": var,
|
|
||||||
"value": "",
|
|
||||||
"config_type": "env",
|
|
||||||
"description": f"Required variable: {var}",
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tool_type_id": tool_type_id,
|
|
||||||
"suggested_configs": defaults,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{config_id}", summary="Delete tool config", description="Delete a tool config.")
|
|
||||||
async def delete_config(
|
|
||||||
config_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
"""Delete a tool config."""
|
|
||||||
config = await session.get(ToolConfig, config_id)
|
|
||||||
if config is None or config.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
|
||||||
|
|
||||||
await session.delete(config)
|
|
||||||
await session.commit()
|
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
@@ -9,7 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.auth.dependencies import get_current_user, get_db_session
|
from src.auth.dependencies import get_current_user, get_db_session
|
||||||
from src.models.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.schemas.tool_type import ToolTypeCreate, ToolTypeResponse, ToolTypeUpdate, ToolTypeValidateRequest
|
from src.schemas.tool_type import (
|
||||||
|
ToolTypeCreate,
|
||||||
|
ToolTypeResponse,
|
||||||
|
ToolTypeUpdate,
|
||||||
|
ToolTypeValidateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
||||||
|
|
||||||
@@ -52,7 +56,10 @@ async def create_tool_type(
|
|||||||
# 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,
|
||||||
@@ -67,7 +74,6 @@ async def create_tool_type(
|
|||||||
required_variables=data.required_variables,
|
required_variables=data.required_variables,
|
||||||
category=data.category,
|
category=data.category,
|
||||||
interfaces=data.interfaces,
|
interfaces=data.interfaces,
|
||||||
is_builtin=False,
|
|
||||||
created_by_id=user.id,
|
created_by_id=user.id,
|
||||||
)
|
)
|
||||||
session.add(tool_type)
|
session.add(tool_type)
|
||||||
@@ -122,7 +128,9 @@ async def get_tool_type(
|
|||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -153,10 +161,15 @@ async def update_tool_type(
|
|||||||
|
|
||||||
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:
|
if tool_type.is_builtin:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types")
|
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)
|
||||||
|
|
||||||
@@ -166,7 +179,7 @@ async def update_tool_type(
|
|||||||
if new_port <= 0 or new_port > 65535:
|
if new_port <= 0 or new_port > 65535:
|
||||||
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="Port must be between 1 and 65535",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only validate port exposure for compose definitions
|
# Only validate port exposure for compose definitions
|
||||||
@@ -183,12 +196,21 @@ async def update_tool_type(
|
|||||||
port_str = str(new_port)
|
port_str = str(new_port)
|
||||||
port_exposed = False
|
port_exposed = False
|
||||||
for service_config in parsed["services"].values():
|
for service_config in parsed["services"].values():
|
||||||
if isinstance(service_config, dict) and "ports" in service_config:
|
if (
|
||||||
|
isinstance(service_config, dict)
|
||||||
|
and "ports" in service_config
|
||||||
|
):
|
||||||
for port_mapping in service_config["ports"]:
|
for port_mapping in service_config["ports"]:
|
||||||
if isinstance(port_mapping, str) and port_str in port_mapping:
|
if (
|
||||||
|
isinstance(port_mapping, str)
|
||||||
|
and port_str in port_mapping
|
||||||
|
):
|
||||||
port_exposed = True
|
port_exposed = True
|
||||||
break
|
break
|
||||||
elif isinstance(port_mapping, int) and port_mapping == new_port:
|
elif (
|
||||||
|
isinstance(port_mapping, int)
|
||||||
|
and port_mapping == new_port
|
||||||
|
):
|
||||||
port_exposed = True
|
port_exposed = True
|
||||||
break
|
break
|
||||||
if port_exposed:
|
if port_exposed:
|
||||||
@@ -197,7 +219,7 @@ async def update_tool_type(
|
|||||||
if not port_exposed:
|
if not port_exposed:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Port {new_port} is not exposed in the compose template"
|
detail=f"Port {new_port} is not exposed in the compose template",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate required variables for compose definitions
|
# Validate required variables for compose definitions
|
||||||
@@ -210,7 +232,7 @@ async def update_tool_type(
|
|||||||
if placeholder not in template:
|
if placeholder not in template:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Required variable '{var}' not found in compose template"
|
detail=f"Required variable '{var}' not found in compose template",
|
||||||
)
|
)
|
||||||
elif "required_variables" in update_data:
|
elif "required_variables" in update_data:
|
||||||
template = tool_type.compose_template
|
template = tool_type.compose_template
|
||||||
@@ -220,7 +242,7 @@ async def update_tool_type(
|
|||||||
if placeholder not in template:
|
if placeholder not in template:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Required variable '{var}' not found in compose template"
|
detail=f"Required variable '{var}' not found in compose template",
|
||||||
)
|
)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
@@ -306,7 +328,9 @@ async def validate_tool_type(
|
|||||||
"""
|
"""
|
||||||
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"
|
||||||
|
)
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
@@ -362,10 +386,15 @@ async def delete_tool_type(
|
|||||||
|
|
||||||
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:
|
if tool_type.is_builtin:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types")
|
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",
|
||||||
|
)
|
||||||
@@ -19,7 +19,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
|
|||||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/me",
|
"/me",
|
||||||
response_model=UserProfileResponse,
|
response_model=UserProfileResponse,
|
||||||
@@ -66,12 +65,16 @@ async def update_profile(
|
|||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
if len(data.name.strip()) == 0:
|
if len(data.name.strip()) == 0:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty"
|
||||||
|
)
|
||||||
user.name = data.name.strip()
|
user.name = data.name.strip()
|
||||||
|
|
||||||
if data.email is not None:
|
if data.email is not None:
|
||||||
if "@" not in data.email:
|
if "@" not in data.email:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email"
|
||||||
|
)
|
||||||
user.email = data.email.strip()
|
user.email = data.email.strip()
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -159,7 +162,7 @@ async def get_user_sessions(
|
|||||||
id=str(inst.id),
|
id=str(inst.id),
|
||||||
display_name=inst.display_name,
|
display_name=inst.display_name,
|
||||||
tool_type_name=inst.tool_type.display_name if inst.tool_type else "Unknown",
|
tool_type_name=inst.tool_type.display_name if inst.tool_type else "Unknown",
|
||||||
tool_icon=inst.tool_type.icon if inst.tool_type else None,
|
tool_icon=None,
|
||||||
tool_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [],
|
tool_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [],
|
||||||
repository_name=inst.repository.name if inst.repository else "Unknown",
|
repository_name=inst.repository.name if inst.repository else "Unknown",
|
||||||
repository_id=str(inst.repository_id),
|
repository_id=str(inst.repository_id),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -15,16 +14,13 @@ 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.terminal import router as terminal_router
|
||||||
from src.api.instance_proxy import router as instance_proxy_router
|
from src.api.instance_proxy import router as instance_proxy_router
|
||||||
from src.api.config_folders import router as config_folders_router
|
|
||||||
from src.api.config_profiles import router as config_profiles_router
|
from src.api.config_profiles import router as config_profiles_router
|
||||||
from src.api.tool_configs import router as tool_configs_router
|
|
||||||
from src.api.tool_instances import router as tool_instances_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.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.config import Settings
|
from src.config import Settings
|
||||||
from src.database import SessionLocal, init_database
|
from src.database import init_database
|
||||||
from src.logging_config import (
|
from src.logging_config import (
|
||||||
ExceptionLoggingMiddleware,
|
ExceptionLoggingMiddleware,
|
||||||
RequestLoggingMiddleware,
|
RequestLoggingMiddleware,
|
||||||
@@ -67,7 +63,9 @@ def _sanitize_validation_errors(errors):
|
|||||||
"type": error.get("type"),
|
"type": error.get("type"),
|
||||||
"loc": error.get("loc"),
|
"loc": error.get("loc"),
|
||||||
"msg": error.get("msg"),
|
"msg": error.get("msg"),
|
||||||
"input": str(error.get("input")) if error.get("input") is not None else None,
|
"input": str(error.get("input"))
|
||||||
|
if error.get("input") is not None
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
# Convert ctx to safe format
|
# Convert ctx to safe format
|
||||||
ctx = error.get("ctx")
|
ctx = error.get("ctx")
|
||||||
@@ -111,12 +109,14 @@ 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
|
# Seed built-in data
|
||||||
await seed_builtin_tool_types()
|
await seed_builtin_tool_types()
|
||||||
logger.info("Startup complete.")
|
logger.info("Startup complete.")
|
||||||
|
|
||||||
|
|
||||||
app.include_router(health_router)
|
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)
|
||||||
@@ -126,11 +126,8 @@ 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(config_folders_router)
|
|
||||||
app.include_router(config_profiles_router)
|
app.include_router(config_profiles_router)
|
||||||
app.include_router(tool_instances_router)
|
app.include_router(tool_instances_router)
|
||||||
app.include_router(tool_configs_router)
|
|
||||||
app.include_router(sessions_router)
|
|
||||||
app.include_router(instance_proxy_router)
|
app.include_router(instance_proxy_router)
|
||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.models.config_folder import ConfigFolder
|
|
||||||
from src.models.config_include import ConfigInclude
|
from src.models.config_include import ConfigInclude
|
||||||
from src.models.config_mount import ConfigMount
|
from src.models.config_mount import ConfigMount
|
||||||
from src.models.config_profile import ConfigProfile
|
from src.models.config_profile import ConfigProfile
|
||||||
@@ -13,7 +12,6 @@ from src.models.user_config import UserConfig
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"ConfigFolder",
|
|
||||||
"ConfigInclude",
|
"ConfigInclude",
|
||||||
"ConfigMount",
|
"ConfigMount",
|
||||||
"ConfigProfile",
|
"ConfigProfile",
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
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 ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
||||||
__tablename__ = "config_folders"
|
|
||||||
|
|
||||||
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)
|
|
||||||
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
|
||||||
files: Mapped[dict] = mapped_column(
|
|
||||||
JSON, default=dict, nullable=False
|
|
||||||
) # {"relative/path": "content", ...}
|
|
||||||
project_overrides: Mapped[dict | None] = mapped_column(
|
|
||||||
JSON, default=dict, nullable=True
|
|
||||||
) # {"project_id": {"mount_path": "...", "files": {...}}}
|
|
||||||
# DEPRECATED: Legacy auto-mounting flag. No longer used for launch-time
|
|
||||||
# auto-mounting. Use ConfigProfile and ToolInstance.selected_profile_id instead.
|
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import 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.project import Project
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
||||||
__tablename__ = "tool_configs"
|
|
||||||
|
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("users.id"), nullable=False
|
|
||||||
)
|
|
||||||
tool_type_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("tool_types.id"), nullable=False
|
|
||||||
)
|
|
||||||
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(), ForeignKey("projects.id"), nullable=True
|
|
||||||
)
|
|
||||||
key: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
value: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
config_type: Mapped[str] = mapped_column(
|
|
||||||
String(20), nullable=False, default="env"
|
|
||||||
) # "env" or "file"
|
|
||||||
file_path: Mapped[str | None] = mapped_column(
|
|
||||||
String(1024), nullable=True
|
|
||||||
) # Only for file type
|
|
||||||
port_override: Mapped[int | None] = mapped_column(nullable=True)
|
|
||||||
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
environment_variables: Mapped[dict | None] = mapped_column(
|
|
||||||
JSON, default=dict, nullable=True
|
|
||||||
)
|
|
||||||
volumes: Mapped[list[dict] | None] = mapped_column(
|
|
||||||
JSON, default=list, nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
|
||||||
project: Mapped["Project | None"] = relationship()
|
|
||||||
@@ -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],
|
||||||
|
)
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
|
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text, Uuid as UUID
|
||||||
from sqlalchemy import Uuid as UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -18,23 +18,54 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
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)
|
||||||
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||||
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
interface_type: Mapped[str] = mapped_column(
|
||||||
|
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)
|
default_port: Mapped[int] = mapped_column(nullable=False)
|
||||||
definition_type: Mapped[str] = mapped_column(
|
definition_type: Mapped[str] = mapped_column(
|
||||||
String(20), nullable=False, default="compose"
|
String(16), nullable=False, default="legacy"
|
||||||
) # "compose" or "dockerfile"
|
) # "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)
|
compose_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
dockerfile_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(
|
build_context: Mapped[dict | None] = mapped_column(
|
||||||
JSON, default=dict, nullable=True
|
JSON, default=dict, nullable=True
|
||||||
)
|
)
|
||||||
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
startup_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
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()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def interfaces(self) -> list[str]:
|
||||||
|
"""Backward-compatible API view for the single interface type."""
|
||||||
|
return [self.interface_type]
|
||||||
|
|
||||||
|
@interfaces.setter
|
||||||
|
def interfaces(self, value: list[str] | str) -> None:
|
||||||
|
"""Accept legacy interface lists and store the first interface type."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
self.interface_type = value
|
||||||
|
return
|
||||||
|
self.interface_type = value[0] if value else "web"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_builtin(self) -> bool:
|
||||||
|
"""Built-in tools are seeded system tools without a creating user."""
|
||||||
|
return self.created_by_id is None
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""Config folder request/response schemas."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderCreate(BaseModel):
|
|
||||||
name: str = Field(description="Folder name")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
mount_path: str = Field(description="Mount path in container")
|
|
||||||
files: dict[str, str] | None = Field(
|
|
||||||
default=None, description="Files as {path: content}"
|
|
||||||
)
|
|
||||||
is_active: bool = Field(default=True, description="Whether folder is active")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderUpdate(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
mount_path: str | None = None
|
|
||||||
files: dict[str, str] | None = None
|
|
||||||
is_active: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectOverrideCreate(BaseModel):
|
|
||||||
project_id: str = Field(description="Project ID to override for")
|
|
||||||
mount_path: str | None = Field(default=None, description="Override mount path")
|
|
||||||
files: dict[str, str] | None = Field(
|
|
||||||
default=None, description="Override files"
|
|
||||||
)
|
|
||||||
is_active: bool | None = Field(default=None, description="Override active state")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_id: str
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
mount_path: str
|
|
||||||
files: dict[str, str] | None
|
|
||||||
is_active: bool
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"""Tool config request/response schemas."""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigCreate(BaseModel):
|
|
||||||
tool_type_id: str = Field(description="UUID of the tool type")
|
|
||||||
key: str = Field(description="Configuration key")
|
|
||||||
value: str = Field(description="Configuration value")
|
|
||||||
config_type: str = Field(default="env", description="Config type: env or file")
|
|
||||||
file_path: str | None = Field(default=None, description="File path for file configs")
|
|
||||||
port_override: int | None = Field(default=None, description="Port override")
|
|
||||||
start_command: str | None = Field(default=None, description="Start command override")
|
|
||||||
working_directory: str | None = Field(default=None, description="Working directory")
|
|
||||||
environment_variables: dict[str, str] | None = Field(
|
|
||||||
default=None, description="Additional environment variables"
|
|
||||||
)
|
|
||||||
volumes: list[dict] | None = Field(default=None, description="Volume mounts")
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigUpdate(BaseModel):
|
|
||||||
value: str | None = None
|
|
||||||
config_type: str | None = None
|
|
||||||
file_path: str | None = None
|
|
||||||
port_override: int | None = None
|
|
||||||
start_command: str | None = None
|
|
||||||
working_directory: str | None = None
|
|
||||||
environment_variables: dict[str, str] | None = None
|
|
||||||
volumes: list[dict] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
tool_type_id: str
|
|
||||||
user_id: str
|
|
||||||
project_id: str | None
|
|
||||||
key: str
|
|
||||||
value: str
|
|
||||||
config_type: str
|
|
||||||
file_path: str | None
|
|
||||||
port_override: int | None
|
|
||||||
start_command: str | None
|
|
||||||
working_directory: str | None
|
|
||||||
environment_variables: dict[str, str] | None
|
|
||||||
volumes: list[dict] | None
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
@@ -46,7 +46,7 @@ async def seed_builtin_tool_types():
|
|||||||
"display_name": "VS Code Server",
|
"display_name": "VS Code Server",
|
||||||
"description": "VS Code running in the browser via code-server",
|
"description": "VS Code running in the browser via code-server",
|
||||||
"category": "editor",
|
"category": "editor",
|
||||||
"interfaces": ["web"],
|
"interface_type": "web",
|
||||||
"compose_template": """version: "3.8"
|
"compose_template": """version: "3.8"
|
||||||
services:
|
services:
|
||||||
code-server:
|
code-server:
|
||||||
@@ -69,7 +69,7 @@ services:
|
|||||||
"display_name": "Jupyter Notebook",
|
"display_name": "Jupyter Notebook",
|
||||||
"description": "Jupyter Lab for interactive development",
|
"description": "Jupyter Lab for interactive development",
|
||||||
"category": "notebook",
|
"category": "notebook",
|
||||||
"interfaces": ["web"],
|
"interface_type": "web",
|
||||||
"default_port": 8888,
|
"default_port": 8888,
|
||||||
"compose_template": """version: "3.8"
|
"compose_template": """version: "3.8"
|
||||||
services:
|
services:
|
||||||
@@ -90,7 +90,7 @@ services:
|
|||||||
"display_name": "OpenCode",
|
"display_name": "OpenCode",
|
||||||
"description": "AI coding assistant - run opencode in terminal",
|
"description": "AI coding assistant - run opencode in terminal",
|
||||||
"category": "ai-assistant",
|
"category": "ai-assistant",
|
||||||
"interfaces": ["terminal"],
|
"interface_type": "terminal",
|
||||||
"default_port": 3000,
|
"default_port": 3000,
|
||||||
"compose_template": """version: "3.8"
|
"compose_template": """version: "3.8"
|
||||||
services:
|
services:
|
||||||
@@ -129,19 +129,20 @@ volumes:
|
|||||||
]
|
]
|
||||||
|
|
||||||
for tool_data in builtin_types:
|
for tool_data in builtin_types:
|
||||||
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
existing = await session.scalar(
|
||||||
|
select(ToolType).where(ToolType.name == tool_data["name"])
|
||||||
|
)
|
||||||
if not existing:
|
if not existing:
|
||||||
tool_type = ToolType(
|
tool_type = ToolType(
|
||||||
name=tool_data["name"],
|
name=tool_data["name"],
|
||||||
display_name=tool_data["display_name"],
|
display_name=tool_data["display_name"],
|
||||||
description=tool_data["description"],
|
description=tool_data["description"],
|
||||||
category=tool_data["category"],
|
category=tool_data["category"],
|
||||||
interfaces=tool_data["interfaces"],
|
interface_type=tool_data["interface_type"],
|
||||||
definition_type="compose",
|
definition_type="compose",
|
||||||
compose_template=tool_data["compose_template"],
|
compose_template=tool_data["compose_template"],
|
||||||
required_variables=tool_data["required_variables"],
|
required_variables=tool_data["required_variables"],
|
||||||
default_port=tool_data.get("default_port"),
|
default_port=tool_data["default_port"],
|
||||||
is_builtin=True,
|
|
||||||
)
|
)
|
||||||
session.add(tool_type)
|
session.add(tool_type)
|
||||||
logger.info("Created built-in tool type: %s", tool_data["name"])
|
logger.info("Created built-in tool type: %s", tool_data["name"])
|
||||||
@@ -150,11 +151,11 @@ volumes:
|
|||||||
existing.display_name = tool_data["display_name"]
|
existing.display_name = tool_data["display_name"]
|
||||||
existing.description = tool_data["description"]
|
existing.description = tool_data["description"]
|
||||||
existing.category = tool_data["category"]
|
existing.category = tool_data["category"]
|
||||||
existing.interfaces = tool_data["interfaces"]
|
existing.interface_type = tool_data["interface_type"]
|
||||||
existing.definition_type = "compose"
|
existing.definition_type = "compose"
|
||||||
existing.compose_template = tool_data["compose_template"]
|
existing.compose_template = tool_data["compose_template"]
|
||||||
existing.required_variables = tool_data["required_variables"]
|
existing.required_variables = tool_data["required_variables"]
|
||||||
existing.default_port = tool_data.get("default_port")
|
existing.default_port = tool_data["default_port"]
|
||||||
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -7,7 +7,7 @@ from .compose import (
|
|||||||
write_compose_file,
|
write_compose_file,
|
||||||
write_env_file,
|
write_env_file,
|
||||||
)
|
)
|
||||||
from .config_staging import write_config_files, write_config_folder_files
|
from .config_staging import write_config_files
|
||||||
from .container import (
|
from .container import (
|
||||||
connect_container_to_network,
|
connect_container_to_network,
|
||||||
find_free_port,
|
find_free_port,
|
||||||
@@ -30,7 +30,6 @@ __all__ = [
|
|||||||
"write_env_file",
|
"write_env_file",
|
||||||
"execute_compose_command",
|
"execute_compose_command",
|
||||||
"write_config_files",
|
"write_config_files",
|
||||||
"write_config_folder_files",
|
|
||||||
"get_container_id",
|
"get_container_id",
|
||||||
"get_container_name",
|
"get_container_name",
|
||||||
"connect_container_to_network",
|
"connect_container_to_network",
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
"""Config folder file staging for Docker instances."""
|
"""Config file staging for Docker instances."""
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||||
"""Write config files to the instance directory.
|
"""Write config files to the instance directory.
|
||||||
|
|
||||||
@@ -24,56 +20,3 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
|||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
|
||||||
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
|
|
||||||
"""Write config folder files to the instance directory and return volume mounts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_dir: Path to instance directory
|
|
||||||
folders: List of ConfigFolder objects
|
|
||||||
project_id: Optional project ID for applying overrides
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
|
|
||||||
"""
|
|
||||||
instance_path = Path(instance_dir)
|
|
||||||
volume_mounts = []
|
|
||||||
|
|
||||||
for folder in folders:
|
|
||||||
# Determine mount path (with project override if applicable)
|
|
||||||
mount_path = folder.mount_path
|
|
||||||
files = folder.files.copy()
|
|
||||||
|
|
||||||
if project_id and folder.project_overrides:
|
|
||||||
override = folder.project_overrides.get(str(project_id))
|
|
||||||
if override:
|
|
||||||
if override.get("mount_path"):
|
|
||||||
mount_path = override["mount_path"]
|
|
||||||
if override.get("files"):
|
|
||||||
files.update(override["files"])
|
|
||||||
|
|
||||||
# Write files to instance directory
|
|
||||||
folder_dir = instance_path / "volumes" / folder.name
|
|
||||||
folder_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for file_path, content in files.items():
|
|
||||||
# Security: ensure path doesn't escape folder_dir
|
|
||||||
full_path = folder_dir / file_path
|
|
||||||
try:
|
|
||||||
full_path.resolve().relative_to(folder_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("Config folder file path escapes directory: %s", file_path)
|
|
||||||
continue
|
|
||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
full_path.write_text(content)
|
|
||||||
|
|
||||||
# Add volume mount
|
|
||||||
volume_mounts.append({
|
|
||||||
"source": str(folder_dir),
|
|
||||||
"target": mount_path,
|
|
||||||
"type": "bind",
|
|
||||||
})
|
|
||||||
|
|
||||||
return volume_mounts
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import subprocess
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dict | None = None) -> tuple[int, str, str]:
|
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.
|
"""Build a Docker image from a Dockerfile.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -18,13 +20,18 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (returncode, stdout, stderr)
|
Tuple of (returncode, stdout, stderr)
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
from pathlib import Path
|
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
|
# Write Dockerfile
|
||||||
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
||||||
dockerfile_path.write_text(dockerfile)
|
dockerfile_path.write_text(dockerfile, newline="\n")
|
||||||
logger.info("Wrote Dockerfile to %s", dockerfile_path)
|
logger.debug("Wrote Dockerfile to %s (%d bytes)", dockerfile_path, len(dockerfile))
|
||||||
|
|
||||||
# Write build context files
|
# Write build context files
|
||||||
if build_context:
|
if build_context:
|
||||||
@@ -34,19 +41,27 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
try:
|
try:
|
||||||
full_path.resolve().relative_to(Path(instance_dir).resolve())
|
full_path.resolve().relative_to(Path(instance_dir).resolve())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error("Build context file path escapes instance directory: %s", file_path)
|
logger.error(
|
||||||
raise ValueError(f"Build context file path '{file_path}' escapes instance directory")
|
"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)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content)
|
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
logger.info("Wrote build context file: %s", full_path)
|
full_path.write_text(normalized, newline="\n")
|
||||||
|
logger.debug("Wrote build context file: %s", full_path)
|
||||||
|
|
||||||
# Build image
|
# Build image
|
||||||
logger.info("Building Docker image with tag: %s", tag)
|
logger.debug("Building Docker image with tag: %s", tag)
|
||||||
cmd = [
|
cmd = [
|
||||||
"docker", "build",
|
"docker",
|
||||||
"-t", tag,
|
"build",
|
||||||
"-f", str(dockerfile_path),
|
"-t",
|
||||||
|
tag,
|
||||||
|
"-f",
|
||||||
|
str(dockerfile_path),
|
||||||
instance_dir,
|
instance_dir,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -57,7 +72,7 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=300, # 5 minute timeout for builds
|
timeout=300, # 5 minute timeout for builds
|
||||||
)
|
)
|
||||||
logger.info("Docker build completed: returncode=%d", result.returncode)
|
logger.debug("Docker build completed: returncode=%d", result.returncode)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error("Docker build failed: %s", result.stderr[:1000])
|
logger.error("Docker build failed: %s", result.stderr[:1000])
|
||||||
return result.returncode, result.stdout, result.stderr
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Git operations for workspace management."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GitService:
|
||||||
|
"""Low-level git operations for creating and syncing workspaces."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prepare_ssh_env(
|
||||||
|
ssh_key: str | None,
|
||||||
|
) -> tuple[dict[str, str] | None, str | None]:
|
||||||
|
"""Prepare environment for git commands with SSH authentication.
|
||||||
|
|
||||||
|
Returns a tuple of (env_dict, temp_key_path). Caller must clean up key_path.
|
||||||
|
"""
|
||||||
|
if not ssh_key:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
||||||
|
try:
|
||||||
|
os.write(fd, ssh_key.encode())
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||||
|
}
|
||||||
|
return env, key_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def clone(
|
||||||
|
remote_url: str, branch: str, path: str, ssh_key: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Clone a repository to the given path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
remote_url: The git remote URL.
|
||||||
|
branch: The branch to clone.
|
||||||
|
path: The destination path for the clone.
|
||||||
|
ssh_key: Optional decrypted SSH private key for authentication.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If the clone fails.
|
||||||
|
"""
|
||||||
|
cmd = [
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"--branch",
|
||||||
|
branch,
|
||||||
|
"--single-branch",
|
||||||
|
remote_url,
|
||||||
|
path,
|
||||||
|
]
|
||||||
|
|
||||||
|
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||||
|
logger.error("Git clone failed: %s", error_msg)
|
||||||
|
raise RuntimeError(f"Git clone failed: {error_msg}")
|
||||||
|
logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path)
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def fetch(path: str, ssh_key: str | None = None) -> None:
|
||||||
|
"""Fetch from origin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The path to the local git repository.
|
||||||
|
ssh_key: Optional decrypted SSH private key for authentication.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If fetch fails.
|
||||||
|
"""
|
||||||
|
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
path,
|
||||||
|
"fetch",
|
||||||
|
"origin",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||||
|
logger.error("Git fetch failed: %s", error_msg)
|
||||||
|
raise RuntimeError(f"Git fetch failed: {error_msg}")
|
||||||
|
logger.debug("Fetched origin for %s", path)
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def pull(path: str, branch: str, ssh_key: str | None = None) -> None:
|
||||||
|
"""Pull latest changes from origin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The path to the local git repository.
|
||||||
|
branch: The branch to pull.
|
||||||
|
ssh_key: Optional decrypted SSH private key for authentication.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If pull fails.
|
||||||
|
"""
|
||||||
|
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
path,
|
||||||
|
"pull",
|
||||||
|
"origin",
|
||||||
|
branch,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||||
|
logger.error("Git pull failed: %s", error_msg)
|
||||||
|
raise RuntimeError(f"Git pull failed: {error_msg}")
|
||||||
|
logger.debug("Pulled origin/%s for %s", branch, path)
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def branch_exists_remotely(
|
||||||
|
path: str, branch: str, ssh_key: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a branch exists on the remote.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The path to the local git repository.
|
||||||
|
branch: The branch name to check.
|
||||||
|
ssh_key: Optional decrypted SSH private key for authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the branch exists on origin, False otherwise.
|
||||||
|
"""
|
||||||
|
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
exists = result.returncode == 0 and result.stdout.strip() != ""
|
||||||
|
logger.debug("Branch %s exists on remote: %s", branch, exists)
|
||||||
|
return exists
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Background health monitor that polls container and tunnel health."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.database import SessionLocal
|
||||||
|
from src.models.health_check import HealthCheck
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.services.correlation import get_correlation_id
|
||||||
|
from src.services.docker import get_container_status
|
||||||
|
from src.services.tunnel import check_tunnel_health
|
||||||
|
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
|
from src.services.notification_service import notification_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthSnapshot:
|
||||||
|
"""In-memory snapshot of an instance's health state."""
|
||||||
|
|
||||||
|
container_status: str | None = None
|
||||||
|
container_healthy: bool | None = None
|
||||||
|
tunnel_healthy: bool | None = None
|
||||||
|
exit_code: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HealthMonitor:
|
||||||
|
"""Polls container and tunnel health, publishing events on state changes."""
|
||||||
|
|
||||||
|
POLL_INTERVAL_SECONDS: float = 15.0
|
||||||
|
_MONITORED_STATUSES: set[str] = {"starting", "running", "unhealthy"}
|
||||||
|
|
||||||
|
def __init__(self, event_bus: InstanceEventBus) -> None:
|
||||||
|
self._event_bus = event_bus
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._last_known_state: dict[uuid.UUID, HealthSnapshot] = {}
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Idempotent start of the background polling task."""
|
||||||
|
if self._task is not None and not self._task.done():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._task = loop.create_task(self._poll_loop())
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Cancel the background task and clear state."""
|
||||||
|
if self._task is not None and not self._task.done():
|
||||||
|
self._task.cancel()
|
||||||
|
self._last_known_state.clear()
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
async def _poll_loop(self) -> None:
|
||||||
|
"""Main polling loop."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self.POLL_INTERVAL_SECONDS)
|
||||||
|
await self._run_check_cycle()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Health monitor poll loop error")
|
||||||
|
|
||||||
|
async def _run_check_cycle(self) -> None:
|
||||||
|
"""Check all monitored instances in one cycle."""
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance).where(
|
||||||
|
ToolInstance.status.in_(self._MONITORED_STATUSES)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
instances = result.scalars().all()
|
||||||
|
|
||||||
|
for instance in instances:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await self._check_instance(session, instance)
|
||||||
|
|
||||||
|
async def _check_instance(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
) -> None:
|
||||||
|
"""Check a single instance and handle state transitions."""
|
||||||
|
try:
|
||||||
|
container_info = get_container_status(instance.container_id or "")
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Health check failed for instance %s",
|
||||||
|
instance.id,
|
||||||
|
extra={
|
||||||
|
"instance_id": str(instance.id),
|
||||||
|
"correlation_id": get_correlation_id(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
container_status = container_info["status"]
|
||||||
|
exit_code = container_info["exit_code"]
|
||||||
|
container_healthy = (
|
||||||
|
container_info["health"] == "healthy" if container_info["health"] else None
|
||||||
|
)
|
||||||
|
|
||||||
|
tunnel_healthy: bool | None = None
|
||||||
|
if instance.public_url and container_status == "running":
|
||||||
|
try:
|
||||||
|
tunnel_result = check_tunnel_health(instance.public_url)
|
||||||
|
tunnel_healthy = tunnel_result.get("healthy", False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Tunnel health check failed for instance %s",
|
||||||
|
instance.id,
|
||||||
|
extra={
|
||||||
|
"instance_id": str(instance.id),
|
||||||
|
"correlation_id": get_correlation_id(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tunnel_healthy = False
|
||||||
|
|
||||||
|
snapshot = HealthSnapshot(
|
||||||
|
container_status=container_status,
|
||||||
|
container_healthy=container_healthy,
|
||||||
|
tunnel_healthy=tunnel_healthy,
|
||||||
|
exit_code=exit_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
previous = self._last_known_state.get(instance.id)
|
||||||
|
|
||||||
|
# Determine new status
|
||||||
|
new_status = self._derive_status(snapshot)
|
||||||
|
|
||||||
|
# If first check or state changed
|
||||||
|
if previous is None or not self._snapshots_equal(previous, snapshot):
|
||||||
|
await self._handle_state_change(
|
||||||
|
session, instance, previous, snapshot, new_status
|
||||||
|
)
|
||||||
|
self._last_known_state[instance.id] = snapshot
|
||||||
|
|
||||||
|
def _derive_status(self, snapshot: HealthSnapshot) -> str:
|
||||||
|
"""Derive instance status from health snapshot."""
|
||||||
|
if snapshot.container_status != "running":
|
||||||
|
return "error"
|
||||||
|
if snapshot.tunnel_healthy is False:
|
||||||
|
return "unhealthy"
|
||||||
|
return "running"
|
||||||
|
|
||||||
|
def _snapshots_equal(self, a: HealthSnapshot, b: HealthSnapshot) -> bool:
|
||||||
|
"""Compare two snapshots for equality."""
|
||||||
|
return (
|
||||||
|
a.container_status == b.container_status
|
||||||
|
and a.container_healthy == b.container_healthy
|
||||||
|
and a.tunnel_healthy == b.tunnel_healthy
|
||||||
|
and a.exit_code == b.exit_code
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_state_change(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
previous: HealthSnapshot | None,
|
||||||
|
snapshot: HealthSnapshot,
|
||||||
|
new_status: str,
|
||||||
|
) -> None:
|
||||||
|
"""Update DB, insert health check, and publish event."""
|
||||||
|
previous_status = instance.status
|
||||||
|
|
||||||
|
# Update instance status
|
||||||
|
instance.status = new_status
|
||||||
|
if new_status == "error":
|
||||||
|
instance.last_stopped_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Insert health check row
|
||||||
|
health_check = HealthCheck(
|
||||||
|
instance_id=instance.id,
|
||||||
|
container_status=snapshot.container_status,
|
||||||
|
container_healthy=snapshot.container_healthy,
|
||||||
|
tunnel_healthy=snapshot.tunnel_healthy,
|
||||||
|
exit_code=snapshot.exit_code,
|
||||||
|
probe_status=None,
|
||||||
|
probe_output=None,
|
||||||
|
)
|
||||||
|
session.add(health_check)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Build event payload
|
||||||
|
correlation_id = get_correlation_id()
|
||||||
|
metadata: dict = {"previous_status": previous_status}
|
||||||
|
if snapshot.exit_code is not None:
|
||||||
|
metadata["exit_code"] = snapshot.exit_code
|
||||||
|
metadata["error_type"] = "container"
|
||||||
|
if instance.public_url:
|
||||||
|
metadata["tunnel_url"] = instance.public_url
|
||||||
|
|
||||||
|
if new_status == "error":
|
||||||
|
event_type = "instance.error"
|
||||||
|
message = f"Container failed with status {snapshot.container_status}"
|
||||||
|
if snapshot.exit_code is not None:
|
||||||
|
message += f" (exit code: {snapshot.exit_code})"
|
||||||
|
else:
|
||||||
|
event_type = "instance.health_changed"
|
||||||
|
message = f"Container is now {new_status}"
|
||||||
|
|
||||||
|
payload: InstanceEventPayload = {
|
||||||
|
"event": event_type,
|
||||||
|
"instance_id": str(instance.id),
|
||||||
|
"status": new_status,
|
||||||
|
"message": message,
|
||||||
|
"metadata": metadata,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
await self._event_bus.publish(event_type, payload)
|
||||||
|
|
||||||
|
# Create notification for instance owner (fire-and-forget)
|
||||||
|
# Only send warnings and errors; skip "recovered" info notifications.
|
||||||
|
if new_status == "error":
|
||||||
|
category = "instance"
|
||||||
|
severity = "error"
|
||||||
|
title = "Container failed"
|
||||||
|
elif new_status == "unhealthy":
|
||||||
|
category = "health"
|
||||||
|
severity = "warning"
|
||||||
|
title = "Container unhealthy"
|
||||||
|
else:
|
||||||
|
# Running/recovered — do not notify
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await notification_service.create_notification(
|
||||||
|
session=session,
|
||||||
|
user_id=instance.owner_id,
|
||||||
|
category=category,
|
||||||
|
severity=severity,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
source_type="tool_instances",
|
||||||
|
source_id=instance.id,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to create notification for health event %s",
|
||||||
|
event_type,
|
||||||
|
extra={"correlation_id": correlation_id},
|
||||||
|
)
|
||||||
@@ -11,14 +11,11 @@ from datetime import datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.models.config_folder import ConfigFolder
|
|
||||||
from src.models.config_profile import ConfigProfile
|
from src.models.config_profile import ConfigProfile
|
||||||
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.tool_config import ToolConfig
|
|
||||||
from src.models.tool_instance import ToolInstance
|
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
|
||||||
@@ -54,7 +51,8 @@ async def create_new_instance(
|
|||||||
|
|
||||||
instance = ToolInstance(
|
instance = ToolInstance(
|
||||||
name=instance_name,
|
name=instance_name,
|
||||||
display_name=display_name or f"{project.name} / {repo.name} / {tool_type.display_name}",
|
display_name=display_name
|
||||||
|
or f"{project.name} / {repo.name} / {tool_type.display_name}",
|
||||||
tool_type_id=tool_type.id,
|
tool_type_id=tool_type.id,
|
||||||
repository_id=repo.id,
|
repository_id=repo.id,
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
@@ -85,16 +83,27 @@ async def start_existing_instance(
|
|||||||
instance.status = "building"
|
instance.status = "building"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
|
env_vars: dict[str, str] = {}
|
||||||
session, user.id, instance.tool_type_id, project_id
|
config_files: dict[str, str] = {}
|
||||||
)
|
port_override = None
|
||||||
|
start_command = None
|
||||||
|
working_directory = None
|
||||||
|
extra_volumes: list[dict] = []
|
||||||
|
|
||||||
selected_profile = None
|
selected_profile = None
|
||||||
if instance.selected_profile_id:
|
if instance.selected_profile_id:
|
||||||
selected_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
selected_profile = await session.get(
|
||||||
|
ConfigProfile, instance.selected_profile_id
|
||||||
|
)
|
||||||
if selected_profile and selected_profile.user_id == user.id:
|
if selected_profile and selected_profile.user_id == user.id:
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
|
(
|
||||||
|
env_vars,
|
||||||
|
port_override,
|
||||||
|
start_command,
|
||||||
|
working_directory,
|
||||||
|
extra_volumes,
|
||||||
|
) = await compose_svc._apply_resolved_profile(
|
||||||
selected_profile,
|
selected_profile,
|
||||||
instance_dir,
|
instance_dir,
|
||||||
env_vars,
|
env_vars,
|
||||||
@@ -104,14 +113,17 @@ async def start_existing_instance(
|
|||||||
extra_volumes,
|
extra_volumes,
|
||||||
)
|
)
|
||||||
|
|
||||||
env_file_path, extra_volumes = await _stage_configs_and_folders(
|
env_file_path, extra_volumes = await _stage_configs(
|
||||||
session, user.id, project_id, os.path.dirname(instance.compose_path),
|
os.path.dirname(instance.compose_path), env_vars, config_files, extra_volumes
|
||||||
env_vars, config_files, extra_volumes
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if port_override or start_command or working_directory or extra_volumes:
|
if port_override or start_command or working_directory or extra_volumes:
|
||||||
compose_svc._modify_compose_file(
|
compose_svc._modify_compose_file(
|
||||||
instance.compose_path, port_override, start_command, working_directory, extra_volumes
|
instance.compose_path,
|
||||||
|
port_override,
|
||||||
|
start_command,
|
||||||
|
working_directory,
|
||||||
|
extra_volumes,
|
||||||
)
|
)
|
||||||
|
|
||||||
returncode, _stdout, stderr = compose_svc.execute_compose_command(
|
returncode, _stdout, stderr = compose_svc.execute_compose_command(
|
||||||
@@ -138,6 +150,14 @@ async def start_existing_instance(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
if not tool_type:
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="tool type not found for instance",
|
||||||
|
)
|
||||||
|
|
||||||
success, probe_logs = await _run_readiness_probe(instance, tool_type)
|
success, probe_logs = await _run_readiness_probe(instance, tool_type)
|
||||||
if not success:
|
if not success:
|
||||||
instance.status = "failed"
|
instance.status = "failed"
|
||||||
@@ -175,27 +195,45 @@ async def restart_existing_instance(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
return {"status": instance.status}
|
return {"status": instance.status}
|
||||||
|
|
||||||
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
|
env_vars: dict[str, str] = {}
|
||||||
session, user.id, instance.tool_type_id, project_id
|
config_files: dict[str, str] = {}
|
||||||
)
|
port_override = None
|
||||||
|
start_command = None
|
||||||
|
working_directory = None
|
||||||
|
extra_volumes: list[dict] = []
|
||||||
|
|
||||||
stored_profile = None
|
stored_profile = None
|
||||||
if instance.selected_profile_id:
|
if instance.selected_profile_id:
|
||||||
stored_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
stored_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
||||||
if stored_profile and stored_profile.user_id == user.id:
|
if stored_profile and stored_profile.user_id == user.id:
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
|
(
|
||||||
stored_profile, instance_dir, env_vars, port_override, start_command, working_directory, extra_volumes
|
env_vars,
|
||||||
|
port_override,
|
||||||
|
start_command,
|
||||||
|
working_directory,
|
||||||
|
extra_volumes,
|
||||||
|
) = await compose_svc._apply_resolved_profile(
|
||||||
|
stored_profile,
|
||||||
|
instance_dir,
|
||||||
|
env_vars,
|
||||||
|
port_override,
|
||||||
|
start_command,
|
||||||
|
working_directory,
|
||||||
|
extra_volumes,
|
||||||
)
|
)
|
||||||
|
|
||||||
env_file_path, extra_volumes = await _stage_configs_and_folders(
|
env_file_path, extra_volumes = await _stage_configs(
|
||||||
session, user.id, project_id, os.path.dirname(instance.compose_path),
|
os.path.dirname(instance.compose_path), env_vars, config_files, extra_volumes
|
||||||
env_vars, config_files, extra_volumes
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if port_override or start_command or working_directory or extra_volumes:
|
if port_override or start_command or working_directory or extra_volumes:
|
||||||
compose_svc._modify_compose_file(
|
compose_svc._modify_compose_file(
|
||||||
instance.compose_path, port_override, start_command, working_directory, extra_volumes
|
instance.compose_path,
|
||||||
|
port_override,
|
||||||
|
start_command,
|
||||||
|
working_directory,
|
||||||
|
extra_volumes,
|
||||||
)
|
)
|
||||||
|
|
||||||
returncode, _stdout, _stderr = compose_svc.execute_compose_command(
|
returncode, _stdout, _stderr = compose_svc.execute_compose_command(
|
||||||
@@ -210,6 +248,11 @@ async def restart_existing_instance(
|
|||||||
instance.last_started_at = datetime.now()
|
instance.last_started_at = datetime.now()
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
if not tool_type:
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
return {"status": instance.status}
|
||||||
|
|
||||||
await _start_tunnel_if_web(instance, tool_type)
|
await _start_tunnel_if_web(instance, tool_type)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -235,7 +278,9 @@ async def stop_existing_instance(session: AsyncSession, instance: ToolInstance)
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def delete_existing_instance(session: AsyncSession, instance: ToolInstance) -> None:
|
async def delete_existing_instance(
|
||||||
|
session: AsyncSession, instance: ToolInstance
|
||||||
|
) -> None:
|
||||||
"""Delete an instance, its containers, and its directory."""
|
"""Delete an instance, its containers, and its directory."""
|
||||||
if instance.tunnel_id:
|
if instance.tunnel_id:
|
||||||
try:
|
try:
|
||||||
@@ -255,6 +300,7 @@ async def delete_existing_instance(session: AsyncSession, instance: ToolInstance
|
|||||||
|
|
||||||
# ── Internal helpers ───────────────────────────────────────────────────────
|
# ── Internal helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _build_or_render_compose(
|
async def _build_or_render_compose(
|
||||||
tool_type: ToolType,
|
tool_type: ToolType,
|
||||||
instance_name: str,
|
instance_name: str,
|
||||||
@@ -283,11 +329,11 @@ async def _build_or_render_compose(
|
|||||||
|
|
||||||
compose_content = (
|
compose_content = (
|
||||||
f'version: "3.8"\nservices:\n app:\n'
|
f'version: "3.8"\nservices:\n app:\n'
|
||||||
f' image: {image_tag}\n'
|
f" image: {image_tag}\n"
|
||||||
f' container_name: {instance_name}\n'
|
f" container_name: {instance_name}\n"
|
||||||
f' ports:\n - "{tool_port}:{tool_type.default_port}"\n'
|
f' ports:\n - "{tool_port}:{tool_type.default_port}"\n'
|
||||||
f' volumes:\n - {repo.path}:/workspace\n'
|
f" volumes:\n - {repo.path}:/workspace\n"
|
||||||
f' restart: unless-stopped\n'
|
f" restart: unless-stopped\n"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
variables = {
|
variables = {
|
||||||
@@ -299,6 +345,11 @@ async def _build_or_render_compose(
|
|||||||
"USER_ID": str(user.id),
|
"USER_ID": str(user.id),
|
||||||
"PROJECT_ID": str(project_id),
|
"PROJECT_ID": str(project_id),
|
||||||
}
|
}
|
||||||
|
if not tool_type.compose_template:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="tool type has no compose template",
|
||||||
|
)
|
||||||
compose_content = compose_svc.render_compose_template(
|
compose_content = compose_svc.render_compose_template(
|
||||||
tool_type.compose_template, variables
|
tool_type.compose_template, variables
|
||||||
)
|
)
|
||||||
@@ -307,80 +358,25 @@ async def _build_or_render_compose(
|
|||||||
return os.path.join(instance_dir, "docker-compose.yml")
|
return os.path.join(instance_dir, "docker-compose.yml")
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_tool_configs(
|
async def _stage_configs(
|
||||||
session: AsyncSession,
|
|
||||||
user_id: Any,
|
|
||||||
tool_type_id: Any,
|
|
||||||
project_id: Any,
|
|
||||||
) -> tuple[dict, dict, Any, Any, Any, dict, list]:
|
|
||||||
"""Fetch tool configs and return parsed values."""
|
|
||||||
env_vars: dict[str, str] = {}
|
|
||||||
config_files: dict[str, str] = {}
|
|
||||||
port_override = None
|
|
||||||
start_command = None
|
|
||||||
working_directory = None
|
|
||||||
extra_env_vars: dict[str, str] = {}
|
|
||||||
extra_volumes: list[dict] = []
|
|
||||||
|
|
||||||
query = (
|
|
||||||
select(ToolConfig)
|
|
||||||
.where(ToolConfig.user_id == user_id, ToolConfig.tool_type_id == tool_type_id)
|
|
||||||
.where((ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)))
|
|
||||||
)
|
|
||||||
configs = (await session.execute(query)).scalars().all()
|
|
||||||
|
|
||||||
for cfg in configs:
|
|
||||||
if cfg.config_type == "env":
|
|
||||||
env_vars[cfg.key] = cfg.value
|
|
||||||
elif cfg.config_type == "file" and cfg.file_path:
|
|
||||||
config_files[cfg.file_path] = cfg.value
|
|
||||||
if cfg.port_override:
|
|
||||||
port_override = cfg.port_override
|
|
||||||
if cfg.start_command:
|
|
||||||
start_command = cfg.start_command
|
|
||||||
if cfg.working_directory:
|
|
||||||
working_directory = cfg.working_directory
|
|
||||||
if cfg.environment_variables:
|
|
||||||
extra_env_vars.update(cfg.environment_variables)
|
|
||||||
if cfg.volumes:
|
|
||||||
extra_volumes.extend(cfg.volumes)
|
|
||||||
|
|
||||||
env_vars.update(extra_env_vars)
|
|
||||||
return env_vars, config_files, port_override, start_command, working_directory, extra_env_vars, extra_volumes
|
|
||||||
|
|
||||||
|
|
||||||
async def _stage_configs_and_folders(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: Any,
|
|
||||||
project_id: Any,
|
|
||||||
instance_dir: str,
|
instance_dir: str,
|
||||||
env_vars: dict[str, str],
|
env_vars: dict[str, str],
|
||||||
config_files: dict[str, str],
|
config_files: dict[str, str],
|
||||||
extra_volumes: list[dict],
|
extra_volumes: list[dict],
|
||||||
) -> tuple[str | None, list[dict]]:
|
) -> tuple[str | None, list[dict]]:
|
||||||
"""Write env/config files and config folders."""
|
"""Write env/config files for the resolved profile."""
|
||||||
env_file_path: str | None = None
|
env_file_path: str | None = None
|
||||||
if env_vars:
|
if env_vars:
|
||||||
env_file_path = compose_svc.write_env_file(instance_dir, env_vars)
|
env_file_path = compose_svc.write_env_file(instance_dir, env_vars)
|
||||||
if config_files:
|
if config_files:
|
||||||
config_staging.write_config_files(instance_dir, config_files)
|
config_staging.write_config_files(instance_dir, config_files)
|
||||||
|
|
||||||
folder_query = select(ConfigFolder).where(
|
|
||||||
ConfigFolder.user_id == user_id, ConfigFolder.is_active.is_(True)
|
|
||||||
)
|
|
||||||
folders = (await session.execute(folder_query)).scalars().all()
|
|
||||||
if folders:
|
|
||||||
folder_volumes = config_staging.write_config_folder_files(
|
|
||||||
instance_dir, folders, str(project_id)
|
|
||||||
)
|
|
||||||
extra_volumes.extend(folder_volumes)
|
|
||||||
|
|
||||||
return env_file_path, extra_volumes
|
return env_file_path, extra_volumes
|
||||||
|
|
||||||
|
|
||||||
async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> None:
|
async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> None:
|
||||||
"""Create Cloudflare tunnel for web-enabled tools."""
|
"""Create Cloudflare tunnel for web-enabled tools."""
|
||||||
if "web" not in tool_type.interfaces or not tool_type.default_port:
|
if tool_type.interface_type != "web" or not tool_type.default_port:
|
||||||
instance.url = None
|
instance.url = None
|
||||||
instance.public_url = None
|
instance.public_url = None
|
||||||
return
|
return
|
||||||
@@ -393,7 +389,9 @@ async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> N
|
|||||||
instance.tunnel_id = tunnel_info["pid"]
|
instance.tunnel_id = tunnel_info["pid"]
|
||||||
instance.public_url = tunnel_info["url"]
|
instance.public_url = tunnel_info["url"]
|
||||||
instance.url = tunnel_info["url"]
|
instance.url = tunnel_info["url"]
|
||||||
logger.info("Created tunnel for instance %s: %s", instance.id, tunnel_info["url"])
|
logger.info(
|
||||||
|
"Created tunnel for instance %s: %s", instance.id, tunnel_info["url"]
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to create tunnel for instance %s: %s", instance.id, exc)
|
logger.error("Failed to create tunnel for instance %s: %s", instance.id, exc)
|
||||||
instance.status = "error"
|
instance.status = "error"
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Lifecycle hook helpers for instrumenting tool instance transitions."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.instance_event import InstanceEvent
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.services.correlation import get_correlation_id
|
||||||
|
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
|
from src.services.notification_service import notification_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_title(event_type: str) -> str:
|
||||||
|
"""Map lifecycle event type to a human-readable notification title."""
|
||||||
|
mapping = {
|
||||||
|
"instance.created": "Container created",
|
||||||
|
"instance.started": "Container started",
|
||||||
|
"instance.stopped": "Container stopped",
|
||||||
|
"instance.restarted": "Container restarted",
|
||||||
|
"instance.deleted": "Container deleted",
|
||||||
|
"instance.error": "Container error",
|
||||||
|
"instance.health_changed": "Container ready",
|
||||||
|
}
|
||||||
|
return mapping.get(
|
||||||
|
event_type,
|
||||||
|
event_type.replace("instance.", "").replace("_", " ").title(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_notify(event_type: str, status: str | None) -> bool:
|
||||||
|
"""Determine whether a lifecycle event should generate a notification.
|
||||||
|
|
||||||
|
Only warnings, errors, and "container is ready" (health_changed running)
|
||||||
|
are sent to users.
|
||||||
|
"""
|
||||||
|
if event_type == "instance.error":
|
||||||
|
return True
|
||||||
|
if event_type == "instance.health_changed" and status == "running":
|
||||||
|
return True
|
||||||
|
# Filter out: created, started, stopped, restarted, deleted, and any
|
||||||
|
# health_changed that is not "running" (unhealthy is handled by health_monitor)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_payload(
|
||||||
|
event_type: str,
|
||||||
|
instance: ToolInstance,
|
||||||
|
status: str | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> InstanceEventPayload:
|
||||||
|
"""Construct a standard event payload."""
|
||||||
|
return {
|
||||||
|
"event": event_type,
|
||||||
|
"instance_id": str(instance.id),
|
||||||
|
"status": status or instance.status,
|
||||||
|
"message": message,
|
||||||
|
"metadata": metadata or {},
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"correlation_id": get_correlation_id(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_audit_row(
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
event_type: str,
|
||||||
|
created_by: uuid.UUID | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> InstanceEvent:
|
||||||
|
"""Persist an instance_events audit row."""
|
||||||
|
row = InstanceEvent(
|
||||||
|
instance_id=instance.id,
|
||||||
|
event_type=event_type.replace("instance.", ""),
|
||||||
|
status=status or instance.status,
|
||||||
|
message=message,
|
||||||
|
created_by=created_by,
|
||||||
|
event_metadata=metadata or {},
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_lifecycle_event(
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
event_type: str,
|
||||||
|
created_by: uuid.UUID | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Publish a lifecycle event and write an audit row after DB commit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_bus: The global event bus.
|
||||||
|
session: Active async DB session.
|
||||||
|
instance: The affected tool instance.
|
||||||
|
event_type: One of instance.created, instance.started, etc.
|
||||||
|
created_by: User ID for user-initiated actions; None for system.
|
||||||
|
status: Optional status override.
|
||||||
|
message: Optional human-readable message.
|
||||||
|
metadata: Optional extra metadata.
|
||||||
|
"""
|
||||||
|
payload = _build_payload(
|
||||||
|
event_type=event_type,
|
||||||
|
instance=instance,
|
||||||
|
status=status,
|
||||||
|
message=message,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write audit row
|
||||||
|
await _write_audit_row(
|
||||||
|
session=session,
|
||||||
|
instance=instance,
|
||||||
|
event_type=event_type,
|
||||||
|
created_by=created_by,
|
||||||
|
status=status or instance.status,
|
||||||
|
message=message,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Publish to bus
|
||||||
|
await event_bus.publish(event_type, payload)
|
||||||
|
|
||||||
|
# Create notification for instance owner (fire-and-forget)
|
||||||
|
# Only send warnings, errors, and "container is ready" notifications.
|
||||||
|
effective_status = status or instance.status
|
||||||
|
if not _should_notify(event_type, effective_status):
|
||||||
|
return
|
||||||
|
|
||||||
|
severity = "error" if event_type == "instance.error" else "success"
|
||||||
|
title = _derive_title(event_type)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await notification_service.create_notification(
|
||||||
|
session=session,
|
||||||
|
user_id=instance.owner_id,
|
||||||
|
category="instance",
|
||||||
|
severity=severity,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
source_type="tool_instances",
|
||||||
|
source_id=instance.id,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to create notification for lifecycle event %s",
|
||||||
|
event_type,
|
||||||
|
extra={"correlation_id": payload.get("correlation_id", "unknown")},
|
||||||
|
)
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
"""Manifest compiler: transforms ToolDefinitionManifest into Dockerfile + Compose."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from src.services.docker import sort_volumes_by_specificity
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_base(manifest: dict) -> dict:
|
||||||
|
"""Merge a base definition into a tool manifest.
|
||||||
|
|
||||||
|
If the manifest has base_definition_id, the base manifest is loaded
|
||||||
|
and merged. Tool-specific values override base values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: The tool manifest JSON (may reference a base)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A fully resolved manifest with base values merged in.
|
||||||
|
"""
|
||||||
|
result = deepcopy(manifest)
|
||||||
|
|
||||||
|
base_definition_id = result.pop("base_definition_id", None)
|
||||||
|
result.pop("base_version", None)
|
||||||
|
|
||||||
|
if base_definition_id:
|
||||||
|
# This will be provided by the caller (they have the DB session)
|
||||||
|
# For now, we assume the manifest has been pre-resolved
|
||||||
|
# or the caller provides the base manifest separately.
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def deep_merge(base: dict, override: dict) -> dict:
|
||||||
|
"""Deep merge two manifests. Arrays are concatenated; dicts are merged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base: The base manifest.
|
||||||
|
override: The tool-specific overrides.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Merged manifest.
|
||||||
|
"""
|
||||||
|
merged = deepcopy(base)
|
||||||
|
|
||||||
|
for key, value in override.items():
|
||||||
|
if key == "mounts" and isinstance(value, list):
|
||||||
|
# Concatenate mount arrays
|
||||||
|
existing = merged.get("mounts", [])
|
||||||
|
merged["mounts"] = existing + deepcopy(value)
|
||||||
|
elif key == "scripts" and isinstance(value, dict):
|
||||||
|
# Merge script categories
|
||||||
|
if "scripts" not in merged:
|
||||||
|
merged["scripts"] = {}
|
||||||
|
for script_key, script_value in value.items():
|
||||||
|
existing = merged["scripts"].get(script_key, [])
|
||||||
|
merged["scripts"][script_key] = existing + deepcopy(script_value)
|
||||||
|
elif key == "packages" and isinstance(value, dict):
|
||||||
|
# Union package arrays
|
||||||
|
if "packages" not in merged:
|
||||||
|
merged["packages"] = {}
|
||||||
|
for pkg_key, pkg_value in value.items():
|
||||||
|
if (
|
||||||
|
pkg_key in merged["packages"]
|
||||||
|
and isinstance(merged["packages"][pkg_key], list)
|
||||||
|
and isinstance(pkg_value, list)
|
||||||
|
):
|
||||||
|
merged["packages"][pkg_key] = merged["packages"][
|
||||||
|
pkg_key
|
||||||
|
] + deepcopy(pkg_value)
|
||||||
|
else:
|
||||||
|
merged["packages"][pkg_key] = deepcopy(pkg_value)
|
||||||
|
elif key == "env" and isinstance(value, dict):
|
||||||
|
# Dict merge: override wins on key conflict
|
||||||
|
if "env" not in merged:
|
||||||
|
merged["env"] = {}
|
||||||
|
merged["env"].update(deepcopy(value))
|
||||||
|
elif (
|
||||||
|
isinstance(value, dict) and key in merged and isinstance(merged[key], dict)
|
||||||
|
):
|
||||||
|
# Generic dict merge
|
||||||
|
merged[key] = {**merged[key], **deepcopy(value)}
|
||||||
|
else:
|
||||||
|
# Override entirely
|
||||||
|
merged[key] = deepcopy(value)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def compile_dockerfile(manifest: dict) -> str:
|
||||||
|
"""Compile a resolved manifest into a Dockerfile string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: Fully resolved manifest JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dockerfile content.
|
||||||
|
"""
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
# FROM
|
||||||
|
base_image = manifest.get("base_image", "ubuntu:24.04")
|
||||||
|
lines.append(f"FROM {base_image}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Build-time environment
|
||||||
|
env = manifest.get("env", {})
|
||||||
|
for key, value in env.items():
|
||||||
|
lines.append(f"ENV {key}={shlex.quote(value)}")
|
||||||
|
if env:
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# System packages (apt)
|
||||||
|
apt_packages = manifest.get("packages", {}).get("apt", [])
|
||||||
|
if manifest.get("user"):
|
||||||
|
# Ensure sudo is available for permission-fixing startup scripts
|
||||||
|
apt_packages = list(apt_packages)
|
||||||
|
if "sudo" not in apt_packages:
|
||||||
|
apt_packages.append("sudo")
|
||||||
|
if apt_packages:
|
||||||
|
lines.append("RUN apt-get update && apt-get install -y \\")
|
||||||
|
for pkg in apt_packages[:-1]:
|
||||||
|
lines.append(f" {pkg} \\")
|
||||||
|
lines.append(f" {apt_packages[-1]} \\")
|
||||||
|
lines.append(" && rm -rf /var/lib/apt/lists/*")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node = manifest.get("packages", {}).get("node")
|
||||||
|
if node:
|
||||||
|
version = node.get("version", "20")
|
||||||
|
lines.append(
|
||||||
|
f"RUN curl -fsSL https://deb.nodesource.com/setup_{version}.x | bash - && \\"
|
||||||
|
)
|
||||||
|
lines.append(" apt-get install -y nodejs && \\")
|
||||||
|
lines.append(" rm -rf /var/lib/apt/lists/*")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# NPM global packages
|
||||||
|
npm_packages = manifest.get("packages", {}).get("npm_global", [])
|
||||||
|
if npm_packages:
|
||||||
|
pkg_list = " ".join(shlex.quote(p) for p in npm_packages)
|
||||||
|
lines.append(f"RUN npm install -g {pkg_list}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Pip packages
|
||||||
|
pip_packages = manifest.get("packages", {}).get("pip", [])
|
||||||
|
if pip_packages:
|
||||||
|
pkg_list = " ".join(shlex.quote(p) for p in pip_packages)
|
||||||
|
lines.append(f"RUN pip install {pkg_list}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# User creation
|
||||||
|
user = manifest.get("user")
|
||||||
|
if user:
|
||||||
|
name = user["name"]
|
||||||
|
uid = user["uid"]
|
||||||
|
gid = user["gid"]
|
||||||
|
create_home = "-m " if user.get("create_home", True) else ""
|
||||||
|
shell = user.get("shell", "/bin/bash")
|
||||||
|
lines.append(f"RUN groupadd -g {gid} {name} && \\")
|
||||||
|
lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}")
|
||||||
|
lines.append("")
|
||||||
|
# Set HOME and USER for runtime compatibility
|
||||||
|
home = f"/home/{name}"
|
||||||
|
lines.append(f"ENV HOME={home}")
|
||||||
|
lines.append(f"ENV USER={name}")
|
||||||
|
lines.append("")
|
||||||
|
# Ensure home directory exists and is writable by the user
|
||||||
|
lines.append(
|
||||||
|
f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Configure passwordless sudo so startup scripts can fix permissions
|
||||||
|
lines.append(
|
||||||
|
f'RUN echo "{name} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/{name} && chmod 0440 /etc/sudoers.d/{name}'
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Build scripts
|
||||||
|
build_scripts = manifest.get("scripts", {}).get("build", [])
|
||||||
|
for script in build_scripts:
|
||||||
|
# Normalize multi-line scripts into single RUN command
|
||||||
|
stripped_lines = [
|
||||||
|
line.strip() for line in script.strip().split("\n") if line.strip()
|
||||||
|
]
|
||||||
|
if stripped_lines:
|
||||||
|
normalized = " && ".join(stripped_lines)
|
||||||
|
lines.append(f"RUN {normalized}")
|
||||||
|
if build_scripts:
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# After build scripts, ensure everything in home is owned by the user
|
||||||
|
if user and build_scripts:
|
||||||
|
lines.append(f"RUN chown -R {name}:{name} {home}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Create mount target directories
|
||||||
|
mounts = manifest.get("mounts", [])
|
||||||
|
if mounts:
|
||||||
|
dirs = [mount["target"] for mount in mounts]
|
||||||
|
dir_str = " ".join(dirs)
|
||||||
|
lines.append(f"RUN mkdir -p {dir_str}")
|
||||||
|
if user:
|
||||||
|
lines.append(f"RUN chown -R {user['name']}:{user['name']} {dir_str}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Entrypoint for startup scripts
|
||||||
|
startup_scripts = manifest.get("scripts", {}).get("startup", [])
|
||||||
|
if startup_scripts:
|
||||||
|
lines.append(
|
||||||
|
"COPY .headquarter/entrypoint.sh /usr/local/bin/headquarter-entrypoint"
|
||||||
|
)
|
||||||
|
lines.append("RUN chmod +x /usr/local/bin/headquarter-entrypoint")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Switch to runtime user
|
||||||
|
if user:
|
||||||
|
lines.append(f"USER {user['name']}")
|
||||||
|
lines.append(f"WORKDIR /home/{user['name']}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Entrypoint and CMD
|
||||||
|
runtime = manifest.get("runtime", {})
|
||||||
|
if startup_scripts:
|
||||||
|
lines.append('ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]')
|
||||||
|
|
||||||
|
command = runtime.get("command", ["/bin/bash"])
|
||||||
|
cmd_json = json.dumps(command)
|
||||||
|
lines.append(f"CMD {cmd_json}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def compile_entrypoint(manifest: dict) -> str:
|
||||||
|
"""Generate the startup entrypoint script from startup scripts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: Fully resolved manifest JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Shell script content.
|
||||||
|
"""
|
||||||
|
lines = ["#!/bin/bash", "set -e", ""]
|
||||||
|
|
||||||
|
startup_scripts = manifest.get("scripts", {}).get("startup", [])
|
||||||
|
for script in startup_scripts:
|
||||||
|
lines.append(script)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append('exec "$@"')
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
||||||
|
"""Compile a resolved manifest into a Docker Compose string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: Fully resolved manifest JSON.
|
||||||
|
variables: Resolved values: IMAGE_TAG, INSTANCE_NAME, REPO_PATH, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Docker Compose YAML content.
|
||||||
|
"""
|
||||||
|
runtime = manifest.get("runtime", {})
|
||||||
|
user = manifest.get("user")
|
||||||
|
interface_type = manifest["interface_type"]
|
||||||
|
|
||||||
|
service: dict[str, Any] = {
|
||||||
|
"image": variables["IMAGE_TAG"],
|
||||||
|
"container_name": variables["INSTANCE_NAME"],
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Terminal-specific fields
|
||||||
|
if runtime.get("stdin_open", False):
|
||||||
|
service["stdin_open"] = True
|
||||||
|
if runtime.get("tty", False):
|
||||||
|
service["tty"] = True
|
||||||
|
if runtime.get("working_dir"):
|
||||||
|
service["working_dir"] = runtime["working_dir"]
|
||||||
|
|
||||||
|
# User override
|
||||||
|
if user:
|
||||||
|
service["user"] = f"{user['uid']}:{user['gid']}"
|
||||||
|
|
||||||
|
# Ports for web tools
|
||||||
|
default_port = manifest.get("default_port")
|
||||||
|
if interface_type == "web" and default_port:
|
||||||
|
service["ports"] = [f"{variables['TOOL_PORT']}:{default_port}"]
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
env = manifest.get("env", {})
|
||||||
|
if env:
|
||||||
|
service["environment"] = dict(env)
|
||||||
|
|
||||||
|
# Merge extra env from config
|
||||||
|
extra_env = variables.get("EXTRA_ENV", {})
|
||||||
|
if extra_env:
|
||||||
|
if "environment" not in service:
|
||||||
|
service["environment"] = {}
|
||||||
|
service["environment"].update(extra_env)
|
||||||
|
|
||||||
|
# Volumes from mount schema
|
||||||
|
volumes = []
|
||||||
|
for mount in manifest.get("mounts", []):
|
||||||
|
source = resolve_mount_source(mount, variables)
|
||||||
|
if not source:
|
||||||
|
continue
|
||||||
|
target = mount["target"]
|
||||||
|
readonly = ":ro" if mount.get("readonly", False) else ""
|
||||||
|
volumes.append(f"{source}:{target}{readonly}")
|
||||||
|
|
||||||
|
# Append extra volumes from tool config / config profile
|
||||||
|
for vol in variables.get("EXTRA_VOLUMES", []):
|
||||||
|
vol_str = f"{vol['source']}:{vol['target']}"
|
||||||
|
if vol.get("readonly"):
|
||||||
|
vol_str += ":ro"
|
||||||
|
volumes.append(vol_str)
|
||||||
|
|
||||||
|
if volumes:
|
||||||
|
service["volumes"] = sort_volumes_by_specificity(volumes)
|
||||||
|
|
||||||
|
compose = {"services": {"app": service}}
|
||||||
|
result = yaml.dump(compose, default_flow_style=False)
|
||||||
|
|
||||||
|
# Debug: log mount resolution so we can diagnose missing mounts
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.debug(
|
||||||
|
"compile_compose: REPO_PATH=%s SSH_PATH=%s EXTRA_VOLUMES=%s mounts=%s volumes=%s",
|
||||||
|
variables.get("REPO_PATH", "<empty>"),
|
||||||
|
variables.get("SSH_PATH", "<empty>"),
|
||||||
|
variables.get("EXTRA_VOLUMES", []),
|
||||||
|
manifest.get("mounts", []),
|
||||||
|
volumes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
|
||||||
|
"""Resolve a mount's source_type to an actual host path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mount: Mount definition from manifest.
|
||||||
|
variables: Resolved variables dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Host path string, or empty string if unresolved.
|
||||||
|
"""
|
||||||
|
source_type = mount.get("source_type", "host_path")
|
||||||
|
|
||||||
|
if source_type == "repo":
|
||||||
|
return variables.get("REPO_PATH", "")
|
||||||
|
elif source_type == "ssh_key":
|
||||||
|
return variables.get("SSH_PATH", "")
|
||||||
|
elif source_type == "instance":
|
||||||
|
instance_dir = variables.get("INSTANCE_DIR", "")
|
||||||
|
mount_name = mount.get("name", "unknown")
|
||||||
|
return f"{instance_dir}/mounts/{mount_name}"
|
||||||
|
elif source_type == "git_mount":
|
||||||
|
ref = mount.get("git_mount_ref", "default")
|
||||||
|
return variables.get(f"GIT_MOUNT_{ref}", "")
|
||||||
|
elif source_type == "host_path":
|
||||||
|
return mount.get("source", "")
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest_home_dir(manifest: dict) -> str:
|
||||||
|
"""Get the home directory for a container based on manifest user config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: Fully resolved manifest JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Home directory path (e.g., /home/user or /root).
|
||||||
|
"""
|
||||||
|
user = manifest.get("user")
|
||||||
|
if user and user.get("name"):
|
||||||
|
return f"/home/{user['name']}"
|
||||||
|
return "/root"
|
||||||
|
|
||||||
|
|
||||||
|
def compute_image_tag(tool_name: str, manifest: dict) -> str:
|
||||||
|
"""Compute a deterministic image tag from manifest content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Human-readable tool name.
|
||||||
|
manifest: Fully resolved manifest JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Docker image tag string.
|
||||||
|
"""
|
||||||
|
# Canonicalize: sort keys, stable JSON
|
||||||
|
canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":"))
|
||||||
|
hash_suffix = hashlib.sha256(canonical.encode()).hexdigest()[:8]
|
||||||
|
safe_name = tool_name.lower().replace(" ", "-").replace("_", "-")
|
||||||
|
return f"headquarter/{safe_name}-{hash_suffix}:latest"
|
||||||
|
|
||||||
|
|
||||||
|
def merge_with_config(manifest: dict, profile: dict | None = None) -> dict:
|
||||||
|
"""Merge ConfigProfile overrides into a manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: Base manifest from tool definition.
|
||||||
|
profile: Resolved ConfigProfile (optional).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Manifest with overrides applied.
|
||||||
|
"""
|
||||||
|
result = deepcopy(manifest)
|
||||||
|
|
||||||
|
extra_env: dict[str, str] = {}
|
||||||
|
extra_volumes: list[dict] = []
|
||||||
|
|
||||||
|
# Apply ConfigProfile
|
||||||
|
if profile:
|
||||||
|
if profile.get("environment_variables"):
|
||||||
|
extra_env.update(profile["environment_variables"])
|
||||||
|
if profile.get("mounts"):
|
||||||
|
extra_volumes.extend(profile["mounts"])
|
||||||
|
# Profile hints override everything
|
||||||
|
hints = profile.get("hints", {})
|
||||||
|
if hints.get("start_command"):
|
||||||
|
result["runtime"] = result.get("runtime", {})
|
||||||
|
result["runtime"]["command"] = hints["start_command"].split()
|
||||||
|
if hints.get("working_directory"):
|
||||||
|
result["runtime"] = result.get("runtime", {})
|
||||||
|
result["runtime"]["working_dir"] = hints["working_directory"]
|
||||||
|
if hints.get("port_override"):
|
||||||
|
result["default_port"] = hints["port_override"]
|
||||||
|
|
||||||
|
# Store merged extras for the compose compiler
|
||||||
|
result["_extra_env"] = extra_env
|
||||||
|
result["_extra_volumes"] = extra_volumes
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""Notification persistence service."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.engine import CursorResult
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.notification import Notification
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationService:
|
||||||
|
"""Singleton notification persistence service.
|
||||||
|
|
||||||
|
All methods filter by user_id to enforce strict ownership isolation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def create_notification(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
category: str,
|
||||||
|
severity: str,
|
||||||
|
title: str,
|
||||||
|
message: str | None = None,
|
||||||
|
source_type: str | None = None,
|
||||||
|
source_id: uuid.UUID | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> Notification:
|
||||||
|
"""Insert a new notification row.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
user_id: Owner of the notification.
|
||||||
|
category: Notification category (e.g., instance, system, health).
|
||||||
|
severity: Severity level (e.g., info, warning, error, success).
|
||||||
|
title: Short notification title.
|
||||||
|
message: Optional longer message body.
|
||||||
|
source_type: Optional source entity type.
|
||||||
|
source_id: Optional source entity UUID.
|
||||||
|
metadata: Optional JSON metadata dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created Notification instance.
|
||||||
|
"""
|
||||||
|
notification = Notification(
|
||||||
|
user_id=user_id,
|
||||||
|
category=category,
|
||||||
|
severity=severity,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
source_type=source_type,
|
||||||
|
source_id=source_id,
|
||||||
|
notification_metadata=metadata or {},
|
||||||
|
)
|
||||||
|
session.add(notification)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(notification)
|
||||||
|
return notification
|
||||||
|
|
||||||
|
async def list_notifications(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
unread_only: bool = False,
|
||||||
|
mute_categories: list[str] | None = None,
|
||||||
|
) -> tuple[list[Notification], int]:
|
||||||
|
"""Return paginated notifications for a user.
|
||||||
|
|
||||||
|
Excludes dismissed notifications and applies optional filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
user_id: Owner of the notifications.
|
||||||
|
limit: Maximum number of items to return.
|
||||||
|
offset: Number of items to skip.
|
||||||
|
unread_only: If True, only return unread notifications.
|
||||||
|
mute_categories: Categories to exclude from results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (items, total_count).
|
||||||
|
"""
|
||||||
|
where_clauses = [
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.dismissed_at.is_(None),
|
||||||
|
]
|
||||||
|
|
||||||
|
if unread_only:
|
||||||
|
where_clauses.append(Notification.read_at.is_(None))
|
||||||
|
|
||||||
|
if mute_categories:
|
||||||
|
where_clauses.append(Notification.category.not_in(mute_categories))
|
||||||
|
|
||||||
|
total_stmt = (
|
||||||
|
select(func.count()).select_from(Notification).where(*where_clauses)
|
||||||
|
)
|
||||||
|
total_result = await session.execute(total_stmt)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
items_stmt = (
|
||||||
|
select(Notification)
|
||||||
|
.where(*where_clauses)
|
||||||
|
.order_by(Notification.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
items_result = await session.execute(items_stmt)
|
||||||
|
items = list(items_result.scalars().all())
|
||||||
|
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
async def get_unread_count(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> int:
|
||||||
|
"""Count unread, non-dismissed notifications for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
user_id: Owner of the notifications.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of unread notifications.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.read_at.is_(None),
|
||||||
|
Notification.dismissed_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
async def mark_read(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> Notification:
|
||||||
|
"""Mark a single notification as read.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
notification_id: UUID of the notification to mark.
|
||||||
|
user_id: Owner of the notification.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated Notification instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the notification does not exist or is not owned by the user.
|
||||||
|
"""
|
||||||
|
notification = await self._get_owned_notification(
|
||||||
|
session, notification_id, user_id
|
||||||
|
)
|
||||||
|
notification.read_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(notification)
|
||||||
|
return notification
|
||||||
|
|
||||||
|
async def mark_all_read(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> int:
|
||||||
|
"""Mark all unread notifications as read for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
user_id: Owner of the notifications.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of rows updated.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
update(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.read_at.is_(None),
|
||||||
|
Notification.dismissed_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(read_at=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
result: CursorResult[Any] = await session.execute(stmt) # type: ignore[assignment]
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
async def dismiss_all(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> int:
|
||||||
|
"""Soft-delete all non-dismissed notifications for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
user_id: Owner of the notifications.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of rows updated.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
update(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.dismissed_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(dismissed_at=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
result: CursorResult[Any] = await session.execute(stmt) # type: ignore[assignment]
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
async def dismiss(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
"""Soft-delete a notification by setting dismissed_at.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
notification_id: UUID of the notification to dismiss.
|
||||||
|
user_id: Owner of the notification.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the notification does not exist or is not owned by the user.
|
||||||
|
"""
|
||||||
|
notification = await self._get_owned_notification(
|
||||||
|
session, notification_id, user_id
|
||||||
|
)
|
||||||
|
notification.dismissed_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def _get_owned_notification(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> Notification:
|
||||||
|
"""Fetch a notification and verify ownership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
notification_id: UUID of the notification.
|
||||||
|
user_id: Expected owner.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The Notification instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the notification does not exist or is not owned.
|
||||||
|
"""
|
||||||
|
notification = await session.get(Notification, notification_id)
|
||||||
|
if notification is None or notification.user_id != user_id:
|
||||||
|
raise ValueError("Notification not found")
|
||||||
|
return notification
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton instance
|
||||||
|
notification_service = NotificationService()
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""Permission fixer: applies mount permission policies post-start."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_mount_permissions(
|
||||||
|
container_id: str,
|
||||||
|
mounts: list[dict],
|
||||||
|
timeout: int = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Apply permission policies to mounted directories in a running container.
|
||||||
|
|
||||||
|
Runs `chown`, `chmod`, and file-mode fixes for each mount that declares
|
||||||
|
an owner, mode, or file_mode. Requires the container to have a root user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
mounts: List of mount definitions from the manifest.
|
||||||
|
timeout: Max seconds per docker exec command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of result dicts: [{mount_name, success, error}]
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for mount in mounts:
|
||||||
|
name = mount.get("name", "unknown")
|
||||||
|
target = mount["target"]
|
||||||
|
owner = mount.get("owner")
|
||||||
|
mode = mount.get("mode")
|
||||||
|
file_mode = mount.get("file_mode")
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"mount_name": name,
|
||||||
|
"success": True,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Skip read-only mounts — their permissions cannot be changed
|
||||||
|
# post-start because the bind mount is locked.
|
||||||
|
if mount.get("readonly", False):
|
||||||
|
logger.debug(
|
||||||
|
"Skipping permission fix for read-only mount %s (target=%s)",
|
||||||
|
name,
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if no permission policy defined
|
||||||
|
if not owner and not mode and not file_mode:
|
||||||
|
results.append(result)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if owner:
|
||||||
|
_run_in_container(
|
||||||
|
container_id,
|
||||||
|
["chown", "-R", f"{owner}:{owner}", target],
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Applied owner %s to %s in container %s",
|
||||||
|
owner,
|
||||||
|
target,
|
||||||
|
container_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode and result["success"]:
|
||||||
|
_run_in_container(
|
||||||
|
container_id,
|
||||||
|
["chmod", mode, target],
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Applied mode %s to %s in container %s",
|
||||||
|
mode,
|
||||||
|
target,
|
||||||
|
container_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if file_mode and result["success"]:
|
||||||
|
_run_in_container(
|
||||||
|
container_id,
|
||||||
|
[
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
f"find {target} -type f -exec chmod {file_mode} {{}} +",
|
||||||
|
],
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Applied file_mode %s to files in %s in container %s",
|
||||||
|
file_mode,
|
||||||
|
target,
|
||||||
|
container_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
except PermissionFixError as exc:
|
||||||
|
result["success"] = False
|
||||||
|
result["error"] = str(exc)
|
||||||
|
logger.warning(
|
||||||
|
"Permission fix failed for mount %s (target=%s): %s",
|
||||||
|
name,
|
||||||
|
target,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_and_log(
|
||||||
|
container_id: str,
|
||||||
|
command: list[str],
|
||||||
|
timeout: int,
|
||||||
|
description: str,
|
||||||
|
) -> str:
|
||||||
|
"""Run a docker exec command and log stdout/stderr for debugging."""
|
||||||
|
cmd = ["docker", "exec", "--user", "root", container_id] + command
|
||||||
|
logger.debug("[SSH-fix] %s: %s", description, " ".join(cmd))
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise PermissionFixError(
|
||||||
|
f"Command timed out after {timeout}s: {' '.join(command)}"
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise PermissionFixError(f"Docker command not found: {' '.join(command)}")
|
||||||
|
|
||||||
|
stdout = result.stdout.strip()
|
||||||
|
stderr = result.stderr.strip()
|
||||||
|
if stdout:
|
||||||
|
logger.debug("[SSH-fix] %s stdout: %s", description, stdout)
|
||||||
|
if stderr:
|
||||||
|
logger.debug("[SSH-fix] %s stderr: %s", description, stderr)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise PermissionFixError(
|
||||||
|
f"Command failed (rc={result.returncode}): {stderr or '(no stderr)'}"
|
||||||
|
)
|
||||||
|
return stdout
|
||||||
|
|
||||||
|
|
||||||
|
def apply_ssh_permissions(
|
||||||
|
container_id: str,
|
||||||
|
ssh_target: str,
|
||||||
|
container_user: str,
|
||||||
|
timeout: int = 10,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fix SSH directory ownership and permissions in a running container.
|
||||||
|
|
||||||
|
Runs chown and chmod on the ~/.ssh directory so the container user
|
||||||
|
can use the keys (SSH requires the private key to be owned by the
|
||||||
|
user with mode 600).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
ssh_target: Absolute path to the .ssh directory inside the container.
|
||||||
|
container_user: The container user that should own the keys.
|
||||||
|
timeout: Max seconds per docker exec command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result dict with keys: success, error.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {"success": True, "error": None}
|
||||||
|
try:
|
||||||
|
# 1. Ensure directory is owned by the container user
|
||||||
|
_exec_and_log(
|
||||||
|
container_id,
|
||||||
|
["chown", "-R", f"{container_user}:{container_user}", ssh_target],
|
||||||
|
timeout,
|
||||||
|
"chown",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Set directory permissions
|
||||||
|
_exec_and_log(
|
||||||
|
container_id,
|
||||||
|
["chmod", "700", ssh_target],
|
||||||
|
timeout,
|
||||||
|
"chmod-dir",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Set private key permissions (id_ed25519, id_rsa, etc.)
|
||||||
|
_exec_and_log(
|
||||||
|
container_id,
|
||||||
|
[
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
f"find {ssh_target} -name 'id_*' -type f -exec chmod 600 {{}} +",
|
||||||
|
],
|
||||||
|
timeout,
|
||||||
|
"chmod-keys",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Verify final state
|
||||||
|
ls_output = _exec_and_log(
|
||||||
|
container_id,
|
||||||
|
["ls", "-la", ssh_target],
|
||||||
|
timeout,
|
||||||
|
"verify-ls",
|
||||||
|
)
|
||||||
|
stat_output = _exec_and_log(
|
||||||
|
container_id,
|
||||||
|
["stat", "-c", "%U:%G %a %n", ssh_target],
|
||||||
|
timeout,
|
||||||
|
"verify-stat-dir",
|
||||||
|
)
|
||||||
|
key_stat = _exec_and_log(
|
||||||
|
container_id,
|
||||||
|
[
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
f"stat -c '%U:%G %a %n' {ssh_target}/id_* 2>/dev/null || echo 'no id_* files found'",
|
||||||
|
],
|
||||||
|
timeout,
|
||||||
|
"verify-stat-keys",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"SSH permissions fixed for container %s (user=%s, target=%s). "
|
||||||
|
"ls:\n%s\nstat-dir: %s\nstat-keys: %s",
|
||||||
|
container_id,
|
||||||
|
container_user,
|
||||||
|
ssh_target,
|
||||||
|
ls_output,
|
||||||
|
stat_output,
|
||||||
|
key_stat,
|
||||||
|
)
|
||||||
|
except PermissionFixError as exc:
|
||||||
|
result["success"] = False
|
||||||
|
result["error"] = str(exc)
|
||||||
|
logger.warning(
|
||||||
|
"SSH permission fix failed for container %s (target=%s): %s",
|
||||||
|
container_id,
|
||||||
|
ssh_target,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionFixError(Exception):
|
||||||
|
"""Raised when a permission fix command fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _run_in_container(
|
||||||
|
container_id: str,
|
||||||
|
command: list[str],
|
||||||
|
timeout: int,
|
||||||
|
) -> None:
|
||||||
|
"""Run a command inside a container as root.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
command: Command + args to execute.
|
||||||
|
timeout: Max seconds to wait.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionFixError: If the command fails or times out.
|
||||||
|
"""
|
||||||
|
cmd = ["docker", "exec", "--user", "root", container_id] + command
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise PermissionFixError(
|
||||||
|
f"Command timed out after {timeout}s: {' '.join(command)}"
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise PermissionFixError(f"Docker command not found: {' '.join(command)}")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise PermissionFixError(
|
||||||
|
f"Command failed (rc={result.returncode}): {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_root_user_available(container_id: str, timeout: int = 5) -> bool:
|
||||||
|
"""Check if the container has a root user we can exec as.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
timeout: Max seconds to wait.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if root user exists and is usable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_run_in_container(container_id, ["id", "root"], timeout)
|
||||||
|
return True
|
||||||
|
except PermissionFixError:
|
||||||
|
return False
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""SSH key service utilities for preparing keys for container use."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
"""Generate a valid Fernet key from the session secret."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
|
||||||
|
key = base64.urlsafe_b64encode(key_bytes)
|
||||||
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_filename(name: str) -> str:
|
||||||
|
"""Sanitize a string for use as a filename.
|
||||||
|
|
||||||
|
Replaces non-alphanumeric characters with underscores and strips
|
||||||
|
leading/trailing underscores.
|
||||||
|
"""
|
||||||
|
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
|
||||||
|
sanitized = sanitized.strip("_")
|
||||||
|
# Ensure it's not empty
|
||||||
|
if not sanitized:
|
||||||
|
sanitized = "key"
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_ssh_key_files(
|
||||||
|
instance_dir: str,
|
||||||
|
ssh_key,
|
||||||
|
subdir: str = ".ssh",
|
||||||
|
uid: int | None = None,
|
||||||
|
gid: int | None = None,
|
||||||
|
key_filename: str = "id_ed25519",
|
||||||
|
write_config: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Decrypt and write SSH key files to instance directory for container mounting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
ssh_key: SSHKey model instance with encrypted private key
|
||||||
|
subdir: Subdirectory within instance_dir to write to (default: ".ssh")
|
||||||
|
uid: Optional UID to own the files (for bind-mount into non-root container)
|
||||||
|
gid: Optional GID to own the files
|
||||||
|
key_filename: Base filename for the key pair (default: "id_ed25519").
|
||||||
|
The private key will be named "{key_filename}" and the public key
|
||||||
|
"{key_filename}.pub".
|
||||||
|
write_config: Whether to write an SSH config file (default: True).
|
||||||
|
Set to False when combining multiple keys into one directory,
|
||||||
|
then call write_ssh_config() separately.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the .ssh directory
|
||||||
|
"""
|
||||||
|
ssh_dir = Path(instance_dir) / subdir
|
||||||
|
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Decrypt private key
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
|
# Write private key with restricted permissions
|
||||||
|
private_key_path = ssh_dir / key_filename
|
||||||
|
private_key_path.write_text(private_key)
|
||||||
|
os.chmod(private_key_path, 0o600)
|
||||||
|
|
||||||
|
# Write public key
|
||||||
|
public_key_path = ssh_dir / f"{key_filename}.pub"
|
||||||
|
public_key_path.write_text(ssh_key.public_key)
|
||||||
|
os.chmod(public_key_path, 0o644)
|
||||||
|
|
||||||
|
# Write SSH config (only if requested)
|
||||||
|
if write_config:
|
||||||
|
config_path = ssh_dir / "config"
|
||||||
|
config_content = f"""Host *
|
||||||
|
StrictHostKeyChecking no
|
||||||
|
UserKnownHostsFile /dev/null
|
||||||
|
IdentityFile ~/.ssh/{key_filename}
|
||||||
|
IdentitiesOnly yes
|
||||||
|
"""
|
||||||
|
config_path.write_text(config_content)
|
||||||
|
os.chmod(config_path, 0o644)
|
||||||
|
|
||||||
|
# Set ownership to target container user if requested
|
||||||
|
if uid is not None or gid is not None:
|
||||||
|
effective_uid = uid if uid is not None else -1
|
||||||
|
effective_gid = gid if gid is not None else -1
|
||||||
|
try:
|
||||||
|
os.chown(ssh_dir, effective_uid, effective_gid)
|
||||||
|
os.chown(private_key_path, effective_uid, effective_gid)
|
||||||
|
os.chown(public_key_path, effective_uid, effective_gid)
|
||||||
|
os.chown(config_path, effective_uid, effective_gid)
|
||||||
|
logger.debug(
|
||||||
|
"Set SSH key ownership to uid=%s gid=%s for %s",
|
||||||
|
effective_uid,
|
||||||
|
effective_gid,
|
||||||
|
ssh_dir,
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Cannot chown SSH keys to uid=%s gid=%s (running as uid=%s): %s",
|
||||||
|
effective_uid,
|
||||||
|
effective_gid,
|
||||||
|
os.getuid(),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Still chown the key files even if we didn't write config
|
||||||
|
if uid is not None or gid is not None:
|
||||||
|
effective_uid = uid if uid is not None else -1
|
||||||
|
effective_gid = gid if gid is not None else -1
|
||||||
|
try:
|
||||||
|
os.chown(private_key_path, effective_uid, effective_gid)
|
||||||
|
os.chown(public_key_path, effective_uid, effective_gid)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return str(ssh_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def write_ssh_config(
|
||||||
|
ssh_dir: str,
|
||||||
|
key_filenames: list[str],
|
||||||
|
uid: int | None = None,
|
||||||
|
gid: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Write an SSH config file that includes multiple IdentityFile entries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ssh_dir: Path to the .ssh directory
|
||||||
|
key_filenames: List of key filenames (without .pub extension)
|
||||||
|
uid: Optional UID to own the config file
|
||||||
|
gid: Optional GID to own the config file
|
||||||
|
"""
|
||||||
|
ssh_dir_path = Path(ssh_dir)
|
||||||
|
ssh_dir_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
config_path = ssh_dir_path / "config"
|
||||||
|
lines = ["Host *"]
|
||||||
|
lines.append(" StrictHostKeyChecking no")
|
||||||
|
lines.append(" UserKnownHostsFile /dev/null")
|
||||||
|
lines.append(" IdentitiesOnly yes")
|
||||||
|
for filename in key_filenames:
|
||||||
|
lines.append(f" IdentityFile ~/.ssh/{filename}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
config_content = "\n".join(lines)
|
||||||
|
config_path.write_text(config_content)
|
||||||
|
os.chmod(config_path, 0o644)
|
||||||
|
|
||||||
|
if uid is not None or gid is not None:
|
||||||
|
effective_uid = uid if uid is not None else -1
|
||||||
|
effective_gid = gid if gid is not None else -1
|
||||||
|
try:
|
||||||
|
os.chown(config_path, effective_uid, effective_gid)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_ssh_key_files(instance_dir: str) -> None:
|
||||||
|
"""Remove temporary SSH key files from instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
"""
|
||||||
|
ssh_dir = Path(instance_dir) / ".ssh"
|
||||||
|
if ssh_dir.exists():
|
||||||
|
for file_path in ssh_dir.iterdir():
|
||||||
|
file_path.unlink()
|
||||||
|
ssh_dir.rmdir()
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Clean tunnel service using cloudflared containers on the backend network.
|
||||||
|
|
||||||
|
Design:
|
||||||
|
- Each tunnel runs as a Docker container on the same 'backend' network as the API.
|
||||||
|
- cloudflared connects to the tool container by its Docker Compose service name
|
||||||
|
(e.g. http://code-server-headquarter-34837cd3:8443).
|
||||||
|
- This avoids host port conflicts and DNS resolution issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.services.docker import get_backend_network_name
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TUNNEL_IMAGE = "cloudflare/cloudflared:latest"
|
||||||
|
|
||||||
|
|
||||||
|
def _tunnel_container_name(instance_name: str) -> str:
|
||||||
|
return f"tunnel-{instance_name.lower()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_image() -> None:
|
||||||
|
"""Pull cloudflared image if not already present."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "images", "-q", TUNNEL_IMAGE],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if not result.stdout.strip():
|
||||||
|
logger.info("Pulling %s ...", TUNNEL_IMAGE)
|
||||||
|
pull = subprocess.run(
|
||||||
|
["docker", "pull", TUNNEL_IMAGE],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if pull.returncode != 0:
|
||||||
|
logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_stale_tunnel(tunnel_name: str) -> None:
|
||||||
|
"""Remove any existing tunnel container with this name."""
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "stop", "-t", "3", tunnel_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", tunnel_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_container_logs(tunnel_name: str) -> tuple[str, str]:
|
||||||
|
"""Get stdout and stderr logs from a container."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "logs", tunnel_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def _get_container_exit_code(tunnel_name: str) -> int | None:
|
||||||
|
"""Get exit code of a container if it has exited."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
try:
|
||||||
|
return int(result.stdout.strip())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def start_tunnel(
|
||||||
|
instance_name: str,
|
||||||
|
container_port: int,
|
||||||
|
timeout: int = 30,
|
||||||
|
target_url: str | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Start a temporary Cloudflare tunnel for an instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_name: The tool instance name (used for tunnel naming).
|
||||||
|
container_port: The port the tool container listens on internally.
|
||||||
|
timeout: Seconds to wait for the tunnel URL.
|
||||||
|
target_url: Optional explicit URL to proxy to. If omitted, derives
|
||||||
|
http://{instance_name.lower()}:{container_port}.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'url' and 'container_name'.
|
||||||
|
"""
|
||||||
|
_ensure_image()
|
||||||
|
|
||||||
|
tunnel_name = _tunnel_container_name(instance_name)
|
||||||
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
|
|
||||||
|
# Target the tool container by name on the backend network
|
||||||
|
if target_url is None:
|
||||||
|
target_url = f"http://{instance_name.lower()}:{container_port}"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--network",
|
||||||
|
get_backend_network_name(),
|
||||||
|
"--name",
|
||||||
|
tunnel_name,
|
||||||
|
TUNNEL_IMAGE,
|
||||||
|
"tunnel",
|
||||||
|
"--no-autoupdate",
|
||||||
|
"--url",
|
||||||
|
target_url,
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.debug("Running: %s", " ".join(cmd))
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to start tunnel container {tunnel_name}: {proc.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
container_id = proc.stdout.strip()
|
||||||
|
logger.debug("Tunnel container started: %s", container_id)
|
||||||
|
|
||||||
|
# Wait for URL to appear in logs
|
||||||
|
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
||||||
|
start_time = __import__("time").time()
|
||||||
|
url: str | None = None
|
||||||
|
combined_logs = ""
|
||||||
|
|
||||||
|
while __import__("time").time() - start_time < timeout:
|
||||||
|
stdout, stderr = _get_container_logs(tunnel_name)
|
||||||
|
combined_logs = stdout + "\n" + stderr
|
||||||
|
|
||||||
|
match = url_pattern.search(combined_logs)
|
||||||
|
if match:
|
||||||
|
url = match.group(0)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if container exited early
|
||||||
|
exit_code = _get_container_exit_code(tunnel_name)
|
||||||
|
if exit_code is not None and exit_code != 0:
|
||||||
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Tunnel container {tunnel_name} exited with code {exit_code}. "
|
||||||
|
f"Logs:\n{combined_logs[-3000:]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
__import__("time").sleep(0.5)
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
stdout, stderr = _get_container_logs(tunnel_name)
|
||||||
|
combined_logs = stdout + "\n" + stderr
|
||||||
|
exit_code = _get_container_exit_code(tunnel_name)
|
||||||
|
|
||||||
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Tunnel {tunnel_name} did not produce a URL within {timeout}s. "
|
||||||
|
f"Exit code: {exit_code}. Logs:\n{combined_logs[-3000:]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait a moment for Cloudflare DNS edge to propagate the new tunnel subdomain
|
||||||
|
__import__("time").sleep(2)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Tunnel %s started for %s → %s (%s)",
|
||||||
|
tunnel_name,
|
||||||
|
instance_name,
|
||||||
|
target_url,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
return {"url": url, "container_name": tunnel_name}
|
||||||
|
|
||||||
|
|
||||||
|
def stop_tunnel(instance_name: str) -> None:
|
||||||
|
"""Stop and remove the tunnel container for an instance."""
|
||||||
|
tunnel_name = _tunnel_container_name(instance_name)
|
||||||
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
|
logger.debug("Stopped and removed tunnel container %s", tunnel_name)
|
||||||
|
|
||||||
|
|
||||||
|
def recreate_tunnel(
|
||||||
|
instance_name: str, container_port: int, target_url: str | None = None
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Recreate a tunnel for an instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_name: The tool instance name.
|
||||||
|
container_port: The port the tool container listens on internally.
|
||||||
|
target_url: Optional explicit origin URL. If omitted, derives
|
||||||
|
http://{instance_name.lower()}:{container_port}.
|
||||||
|
"""
|
||||||
|
stop_tunnel(instance_name)
|
||||||
|
return start_tunnel(instance_name, container_port, target_url=target_url)
|
||||||
|
|
||||||
|
|
||||||
|
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||||
|
"""Check if a tunnel URL is healthy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'tunnel_status', 'status_code', 'healthy', 'error'.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
str(timeout),
|
||||||
|
url,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout + 5,
|
||||||
|
)
|
||||||
|
status_code = int(result.stdout.strip())
|
||||||
|
|
||||||
|
if 200 <= status_code < 400:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "healthy",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": True,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
if status_code in (502, 503, 504):
|
||||||
|
return {
|
||||||
|
"tunnel_status": "error_response",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"Application returned HTTP {status_code}",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"tunnel_status": "error_response",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"HTTP {status_code}",
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": "Tunnel request timed out",
|
||||||
|
}
|
||||||
|
except (ValueError, Exception) as exc:
|
||||||
|
error_str = str(exc).lower()
|
||||||
|
if any(
|
||||||
|
err in error_str
|
||||||
|
for err in [
|
||||||
|
"connection refused",
|
||||||
|
"econnrefused",
|
||||||
|
"could not resolve",
|
||||||
|
"nodename",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"Tunnel unreachable: {exc}",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Workspace lifecycle management service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
from src.services.git_service import GitService
|
||||||
|
from src.services.ssh_keys import _get_fernet
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SyncResult:
|
||||||
|
"""Result of a workspace sync operation."""
|
||||||
|
|
||||||
|
branch_deleted: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceHasInstancesError(Exception):
|
||||||
|
"""Raised when attempting to delete a workspace with running instances."""
|
||||||
|
|
||||||
|
def __init__(self, instances: list[dict]) -> None:
|
||||||
|
self.instances = instances
|
||||||
|
super().__init__(f"Workspace has {len(instances)} running tool instance(s)")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceManager:
|
||||||
|
"""Manages workspace lifecycle: create, delete, sync, validate."""
|
||||||
|
|
||||||
|
BASE_PATH = "/data/working-copies"
|
||||||
|
|
||||||
|
def _workspace_path(self, repo_id: uuid.UUID, name: str) -> str:
|
||||||
|
"""Return the filesystem path for a workspace."""
|
||||||
|
return os.path.join(self.BASE_PATH, str(repo_id), name)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
repo: GitRepository,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
name: str,
|
||||||
|
branch: str = "main",
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Workspace:
|
||||||
|
"""Clone repo to workspace path and create DB record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: The git repository to clone.
|
||||||
|
user_id: The owner user ID.
|
||||||
|
name: The workspace name (unique per repo).
|
||||||
|
branch: The branch to clone (default: "main").
|
||||||
|
session: Database session for loading SSH keys.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created Workspace record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If git clone fails.
|
||||||
|
"""
|
||||||
|
path = self._workspace_path(repo.id, name)
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
# Ensure container users (various UIDs) can write to workspace dirs
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.chmod(parent, 0o777)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch
|
||||||
|
)
|
||||||
|
|
||||||
|
if not repo.remote_url:
|
||||||
|
raise ValueError("Repository has no remote URL")
|
||||||
|
|
||||||
|
# Remove stale directory from previous failed/aborted clone
|
||||||
|
if os.path.exists(path):
|
||||||
|
logger.warning("Removing stale workspace directory: %s", path)
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
|
||||||
|
# Load SSH key if repo has one
|
||||||
|
ssh_key = None
|
||||||
|
if getattr(repo, "ssh_key_id", None) and session is not None:
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(SSHKey).where(SSHKey.id == repo.ssh_key_id)
|
||||||
|
)
|
||||||
|
ssh_key_obj = result.scalar_one_or_none()
|
||||||
|
if ssh_key_obj:
|
||||||
|
fernet = _get_fernet()
|
||||||
|
ssh_key = fernet.decrypt(
|
||||||
|
ssh_key_obj.private_key_encrypted.encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key)
|
||||||
|
self._make_world_writable(path)
|
||||||
|
|
||||||
|
workspace = Workspace(
|
||||||
|
name=name,
|
||||||
|
repo_id=repo.id,
|
||||||
|
user_id=user_id,
|
||||||
|
branch=branch,
|
||||||
|
path=path,
|
||||||
|
status="ready",
|
||||||
|
last_sync_at=datetime.now(),
|
||||||
|
)
|
||||||
|
logger.info("Workspace created: %s", workspace.id)
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
async def delete(
|
||||||
|
self,
|
||||||
|
workspace: Workspace,
|
||||||
|
force: bool = False,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Delete a workspace and all associated tool instances.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace: The workspace to delete.
|
||||||
|
force: If True, delete even if instances exist.
|
||||||
|
session: The database session (required for checking instances).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WorkspaceHasInstancesError: If instances exist and force=False.
|
||||||
|
"""
|
||||||
|
if session is None:
|
||||||
|
raise ValueError("session is required for delete")
|
||||||
|
|
||||||
|
instances = await self._get_instances(workspace, session)
|
||||||
|
if instances and not force:
|
||||||
|
raise WorkspaceHasInstancesError(
|
||||||
|
[{"id": str(i.id), "name": i.name} for i in instances]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stop and delete all instances
|
||||||
|
for instance in instances:
|
||||||
|
await self._stop_and_delete_instance(instance)
|
||||||
|
|
||||||
|
# Delete directory
|
||||||
|
if os.path.exists(workspace.path):
|
||||||
|
shutil.rmtree(workspace.path, ignore_errors=True)
|
||||||
|
logger.info("Deleted workspace directory: %s", workspace.path)
|
||||||
|
|
||||||
|
# Delete record
|
||||||
|
await session.delete(workspace)
|
||||||
|
logger.info("Deleted workspace record: %s", workspace.id)
|
||||||
|
|
||||||
|
async def sync(
|
||||||
|
self, workspace: Workspace, session: AsyncSession | None = None
|
||||||
|
) -> SyncResult:
|
||||||
|
"""Sync a workspace with its remote.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace: The workspace to sync.
|
||||||
|
session: Database session for loading SSH keys.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SyncResult indicating whether the branch was deleted.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If git operations fail.
|
||||||
|
"""
|
||||||
|
logger.info("Syncing workspace: %s", workspace.id)
|
||||||
|
|
||||||
|
# Load SSH key if repo has one
|
||||||
|
ssh_key = None
|
||||||
|
if session is not None:
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
|
|
||||||
|
repo = await session.get(GitRepository, workspace.repo_id)
|
||||||
|
if repo and getattr(repo, "ssh_key_id", None):
|
||||||
|
result = await session.execute(
|
||||||
|
select(SSHKey).where(SSHKey.id == repo.ssh_key_id)
|
||||||
|
)
|
||||||
|
ssh_key_obj = result.scalar_one_or_none()
|
||||||
|
if ssh_key_obj:
|
||||||
|
fernet = _get_fernet()
|
||||||
|
ssh_key = fernet.decrypt(
|
||||||
|
ssh_key_obj.private_key_encrypted.encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
await GitService.fetch(workspace.path, ssh_key=ssh_key)
|
||||||
|
|
||||||
|
if not GitService.branch_exists_remotely(
|
||||||
|
workspace.path, workspace.branch, ssh_key=ssh_key
|
||||||
|
):
|
||||||
|
return SyncResult(branch_deleted=True)
|
||||||
|
|
||||||
|
await GitService.pull(workspace.path, workspace.branch, ssh_key=ssh_key)
|
||||||
|
self._make_world_writable(workspace.path)
|
||||||
|
|
||||||
|
workspace.last_sync_at = datetime.now()
|
||||||
|
logger.info("Workspace synced: %s", workspace.id)
|
||||||
|
return SyncResult(branch_deleted=False)
|
||||||
|
|
||||||
|
def _make_world_writable(self, path: str) -> None:
|
||||||
|
"""Recursively make path readable/writable/traversable by any UID.
|
||||||
|
|
||||||
|
Directories get 777 (traversable). Files get rw for all while
|
||||||
|
preserving any existing execute bits.
|
||||||
|
"""
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.chmod(path, 0o777)
|
||||||
|
for root, dirs, files in os.walk(path):
|
||||||
|
for d in dirs:
|
||||||
|
dpath = os.path.join(root, d)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.chmod(dpath, 0o777)
|
||||||
|
for f in files:
|
||||||
|
fpath = os.path.join(root, f)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
mode = os.stat(fpath).st_mode
|
||||||
|
# Preserve execute bits, ensure read+write for all
|
||||||
|
new_mode = (mode & stat.S_IXUSR) | 0o666
|
||||||
|
if mode & stat.S_IXGRP:
|
||||||
|
new_mode |= stat.S_IXGRP
|
||||||
|
if mode & stat.S_IXOTH:
|
||||||
|
new_mode |= stat.S_IXOTH
|
||||||
|
os.chmod(fpath, new_mode)
|
||||||
|
|
||||||
|
async def _get_instances(
|
||||||
|
self,
|
||||||
|
workspace: Workspace,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> list[ToolInstance]:
|
||||||
|
"""Get all tool instances associated with this workspace."""
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance).where(ToolInstance.workspace_id == workspace.id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def _stop_and_delete_instance(self, instance: ToolInstance) -> None:
|
||||||
|
"""Stop and delete a tool instance.
|
||||||
|
|
||||||
|
TODO(PR-2): Wire up to actual instance stop/delete logic.
|
||||||
|
For now, this is a placeholder.
|
||||||
|
"""
|
||||||
|
logger.warning("Placeholder: stopping and deleting instance %s", instance.id)
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def _run_git_command(repo_path: str, *args: str) -> str:
|
def _run_git_command(repo_path: str, *args: str) -> str:
|
||||||
@@ -124,7 +123,15 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
|
|||||||
try:
|
try:
|
||||||
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
|
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
_run_git_command(repo_path, "checkout", "--orphan", name)
|
# No commits yet - empty repository
|
||||||
|
try:
|
||||||
|
_run_git_command(repo_path, "checkout", "--orphan", name)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "work tree" in str(e).lower():
|
||||||
|
# Bare repository - use symbolic-ref instead
|
||||||
|
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
|
||||||
|
return
|
||||||
|
raise
|
||||||
return
|
return
|
||||||
|
|
||||||
_run_git_command(repo_path, "branch", name, base_branch)
|
_run_git_command(repo_path, "branch", name, base_branch)
|
||||||
@@ -155,7 +162,14 @@ def checkout_branch(repo_path: str, name: str) -> None:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If checkout fails
|
RuntimeError: If checkout fails
|
||||||
"""
|
"""
|
||||||
_run_git_command(repo_path, "checkout", name)
|
try:
|
||||||
|
_run_git_command(repo_path, "checkout", name)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "work tree" in str(e).lower():
|
||||||
|
# Bare repository - use symbolic-ref instead
|
||||||
|
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def commit_changes(
|
def commit_changes(
|
||||||
@@ -290,6 +304,10 @@ def get_current_branch(repo_path: str) -> str:
|
|||||||
Current branch name
|
Current branch name
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||||
|
if branch != "HEAD":
|
||||||
|
return branch
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
pass
|
||||||
|
|
||||||
|
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||||
|
|||||||
@@ -289,19 +289,37 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
|
|||||||
branches: list[BranchInfo] = []
|
branches: list[BranchInfo] = []
|
||||||
default_branch = "main"
|
default_branch = "main"
|
||||||
|
|
||||||
|
# Get list of remote names to properly filter remote tracking branches
|
||||||
|
try:
|
||||||
|
remote_output = _run_git_command(repo_path, "remote")
|
||||||
|
remote_names = {r.strip() for r in remote_output.strip().split("\n") if r.strip()}
|
||||||
|
except RuntimeError:
|
||||||
|
remote_names = set()
|
||||||
|
|
||||||
for line in output.strip().split("\n"):
|
for line in output.strip().split("\n"):
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
branch_name = line.strip()
|
branch_name = line.strip()
|
||||||
# Skip remote tracking branches (they start with remotes/)
|
|
||||||
if branch_name.startswith("remotes/"):
|
# Skip detached HEAD pointer
|
||||||
# Extract just the branch name part
|
if branch_name == "HEAD":
|
||||||
parts = branch_name.split("/", 2)
|
continue
|
||||||
if len(parts) >= 3:
|
|
||||||
branch_name = parts[2]
|
# Skip remote tracking branches - they appear as "origin/branch-name"
|
||||||
else:
|
# Check if first part is a remote name
|
||||||
continue
|
if "/" in branch_name:
|
||||||
|
first_part = branch_name.split("/", 1)[0]
|
||||||
|
if first_part in remote_names:
|
||||||
|
# Extract just the branch name part (after "origin/")
|
||||||
|
branch_name = branch_name.split("/", 1)[1]
|
||||||
|
elif branch_name.startswith("remotes/"):
|
||||||
|
# Handle "remotes/origin/branch-name" format
|
||||||
|
parts = branch_name.split("/", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
branch_name = parts[2]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
# Skip duplicates
|
# Skip duplicates
|
||||||
if any(b.name == branch_name for b in branches):
|
if any(b.name == branch_name for b in branches):
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Integration tests for multi-session terminal WebSocket and REST API."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from src.main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminalWebSocketMultiSession:
|
||||||
|
"""Tests for multi-session WebSocket routing."""
|
||||||
|
|
||||||
|
def test_specific_session_websocket_route_exists(self, client):
|
||||||
|
"""The specific session WebSocket route should be registered."""
|
||||||
|
# We can't easily test WebSocket without auth, but we can verify
|
||||||
|
# the route exists by checking for a 403 (no auth cookie)
|
||||||
|
response = client.get("/ws/tool-instances/test-instance/terminal/test-session")
|
||||||
|
# WebSocket endpoint returns 403 when accessed via HTTP GET
|
||||||
|
assert response.status_code in (403, 404)
|
||||||
|
|
||||||
|
def test_default_session_alias_route_exists(self, client):
|
||||||
|
"""The default session alias route should still exist."""
|
||||||
|
response = client.get("/ws/tool-instances/test-instance/terminal")
|
||||||
|
assert response.status_code in (403, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminalRestApi:
|
||||||
|
"""Tests for REST API endpoints."""
|
||||||
|
|
||||||
|
def test_list_sessions_requires_auth(self, client):
|
||||||
|
"""List sessions endpoint requires authentication."""
|
||||||
|
response = client.get("/instances/test/terminal/sessions")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_create_session_requires_auth(self, client):
|
||||||
|
"""Create session endpoint requires authentication."""
|
||||||
|
response = client.post(
|
||||||
|
"/instances/test/terminal/sessions",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_close_session_requires_auth(self, client):
|
||||||
|
"""Close session endpoint requires authentication."""
|
||||||
|
response = client.delete("/instances/test/terminal/sessions/test-session")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_reset_session_requires_auth(self, client):
|
||||||
|
"""Reset session endpoint requires authentication."""
|
||||||
|
response = client.post("/instances/test/terminal/sessions/test-session/reset")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_rename_session_requires_auth(self, client):
|
||||||
|
"""Rename session endpoint requires authentication."""
|
||||||
|
response = client.post(
|
||||||
|
"/instances/test/terminal/sessions/test-session/rename",
|
||||||
|
json={"name": "New Name"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_legacy_reset_alias_requires_auth(self, client):
|
||||||
|
"""Legacy reset endpoint still requires auth."""
|
||||||
|
response = client.post("/instances/test/terminal/reset")
|
||||||
|
assert response.status_code == 401
|
||||||
@@ -8,16 +8,14 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
# Set test environment BEFORE importing app modules
|
# Set test environment BEFORE importing app modules
|
||||||
os.environ["APP_ENV"] = "testing"
|
os.environ["APP_ENV"] = "testing"
|
||||||
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
||||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings
|
||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.main import app
|
from src.main import app
|
||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
@@ -47,10 +45,8 @@ def test_client() -> Generator[TestClient, None, None]:
|
|||||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||||
|
|
||||||
# Patch startup events to prevent PostgreSQL connection attempts
|
# Patch startup events to prevent PostgreSQL connection attempts
|
||||||
with patch("src.main.init_database") as mock_init, \
|
with patch("src.main.init_database") as mock_init:
|
||||||
patch("src.main.seed_builtin_tool_types") as mock_seed:
|
|
||||||
mock_init.return_value = True
|
mock_init.return_value = True
|
||||||
mock_seed.return_value = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
@@ -61,6 +57,31 @@ def test_client() -> Generator[TestClient, None, None]:
|
|||||||
asyncio.run(engine.dispose())
|
asyncio.run(engine.dispose())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session(test_client) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Provide an async database session for unit tests."""
|
||||||
|
# Get the override function from the test_client fixture
|
||||||
|
override_fn = app.dependency_overrides.get(get_db_session)
|
||||||
|
if override_fn:
|
||||||
|
gen = override_fn()
|
||||||
|
session = await gen.asend(None)
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await gen.aclose()
|
||||||
|
else:
|
||||||
|
# Fallback: create a new engine and session
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||||
|
yield session
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
||||||
"""Provide an authenticated test client with a test user."""
|
"""Provide an authenticated test client with a test user."""
|
||||||
@@ -110,6 +131,65 @@ def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
|||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_project_and_repo(authenticated_client) -> tuple[str, str]:
|
||||||
|
"""Create a project and repository directly in the database."""
|
||||||
|
import uuid
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
|
||||||
|
project_id = uuid.uuid4()
|
||||||
|
repo_id = uuid.uuid4()
|
||||||
|
user_id = None
|
||||||
|
|
||||||
|
# Get user ID from session
|
||||||
|
async def get_user_id():
|
||||||
|
nonlocal user_id
|
||||||
|
from src.auth.session import decode_session_cookie
|
||||||
|
settings = Settings()
|
||||||
|
session_cookie = authenticated_client.cookies.get("session")
|
||||||
|
if session_cookie:
|
||||||
|
session = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
|
if session:
|
||||||
|
user_id = uuid.UUID(session["user_id"])
|
||||||
|
|
||||||
|
asyncio.run(get_user_id())
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise RuntimeError("Could not get user ID from authenticated client")
|
||||||
|
|
||||||
|
async def create_project_and_repo():
|
||||||
|
override_fn = app.dependency_overrides.get(get_db_session)
|
||||||
|
if override_fn:
|
||||||
|
gen = override_fn()
|
||||||
|
session = await gen.asend(None)
|
||||||
|
try:
|
||||||
|
project = Project(
|
||||||
|
id=project_id,
|
||||||
|
name="test-project",
|
||||||
|
description="Test project",
|
||||||
|
owner_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(project)
|
||||||
|
|
||||||
|
repo = GitRepository(
|
||||||
|
id=repo_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/tmp/test-repo",
|
||||||
|
project_id=project_id,
|
||||||
|
owner_id=user_id,
|
||||||
|
remote_url="https://github.com/test/repo.git",
|
||||||
|
)
|
||||||
|
session.add(repo)
|
||||||
|
await session.commit()
|
||||||
|
finally:
|
||||||
|
await gen.aclose()
|
||||||
|
|
||||||
|
asyncio.run(create_project_and_repo())
|
||||||
|
|
||||||
|
return str(project_id), str(repo_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
||||||
"""Provide an authenticated test client with an admin user."""
|
"""Provide an authenticated test client with an admin user."""
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
import uuid
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestConfigFoldersAPI:
|
|
||||||
"""Integration tests for config folders API."""
|
|
||||||
|
|
||||||
def test_list_config_folders_requires_authentication(self, test_client: TestClient) -> None:
|
|
||||||
"""Test that listing config folders requires authentication."""
|
|
||||||
response = test_client.get("/config-folders")
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
def test_list_config_folders_returns_user_folders(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that authenticated users can list their folders."""
|
|
||||||
response = authenticated_client.get("/config-folders")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, dict)
|
|
||||||
assert "folders" in data
|
|
||||||
assert isinstance(data["folders"], list)
|
|
||||||
|
|
||||||
def test_create_config_folder_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test creating a config folder."""
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "test-folder",
|
|
||||||
"description": "Test folder",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {"test.txt": "hello world"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["name"] == "test-folder"
|
|
||||||
assert data["mount_path"] == "/home/user"
|
|
||||||
assert data["files"] == {"test.txt": "hello world"}
|
|
||||||
|
|
||||||
def test_create_config_folder_duplicate_name(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that duplicate folder names are rejected."""
|
|
||||||
# Create first folder
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "duplicate-folder",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
# Try to create second with same name
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "duplicate-folder",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 409
|
|
||||||
|
|
||||||
def test_create_config_folder_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that folders exceeding 10MB are rejected."""
|
|
||||||
large_content = "x" * (11 * 1024 * 1024) # 11MB
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "large-folder",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {"large.txt": large_content},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
def test_create_config_folder_path_traversal_attack(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that path traversal in file paths is prevented."""
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "bad-folder",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {"../../../etc/passwd": "malicious"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
def test_get_config_folder_by_id(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test getting a config folder by ID."""
|
|
||||||
# Create folder first
|
|
||||||
create_response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "get-test",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
folder_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Get it back
|
|
||||||
response = authenticated_client.get(f"/config-folders/{folder_id}")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["name"] == "get-test"
|
|
||||||
|
|
||||||
def test_get_config_folder_not_found(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test getting a non-existent folder."""
|
|
||||||
response = authenticated_client.get(f"/config-folders/{uuid.uuid4()}")
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
def test_update_config_folder_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test updating a config folder."""
|
|
||||||
# Create folder first
|
|
||||||
create_response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "update-test",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
folder_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Update it
|
|
||||||
response = authenticated_client.put(
|
|
||||||
f"/config-folders/{folder_id}",
|
|
||||||
json={
|
|
||||||
"name": "updated-name",
|
|
||||||
"mount_path": "/workspace",
|
|
||||||
"files": {"new.txt": "content"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["name"] == "updated-name"
|
|
||||||
assert data["mount_path"] == "/workspace"
|
|
||||||
|
|
||||||
def test_delete_config_folder_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test deleting a config folder."""
|
|
||||||
# Create folder first
|
|
||||||
create_response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "delete-test",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
folder_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Delete it
|
|
||||||
response = authenticated_client.delete(f"/config-folders/{folder_id}")
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
# Verify it's gone
|
|
||||||
get_response = authenticated_client.get(f"/config-folders/{folder_id}")
|
|
||||||
assert get_response.status_code == 404
|
|
||||||
|
|
||||||
def test_add_project_override_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test adding a project override."""
|
|
||||||
# Create folder first
|
|
||||||
create_response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "override-test",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {"global.txt": "global"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
folder_id = create_response.json()["id"]
|
|
||||||
project_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Add override
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-folders/{folder_id}/overrides",
|
|
||||||
json={
|
|
||||||
"project_id": project_id,
|
|
||||||
"mount_path": "/workspace",
|
|
||||||
"files": {"project.txt": "project"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert project_id in data["project_overrides"]
|
|
||||||
|
|
||||||
def test_update_project_override_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test updating a project override."""
|
|
||||||
# Create folder with override
|
|
||||||
create_response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "update-override-test",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
folder_id = create_response.json()["id"]
|
|
||||||
project_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Add override
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-folders/{folder_id}/overrides",
|
|
||||||
json={
|
|
||||||
"project_id": project_id,
|
|
||||||
"mount_path": "/workspace",
|
|
||||||
"files": {"old.txt": "old"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update override
|
|
||||||
response = authenticated_client.put(
|
|
||||||
f"/config-folders/{folder_id}/overrides/{project_id}",
|
|
||||||
json={
|
|
||||||
"mount_path": "/app",
|
|
||||||
"files": {"new.txt": "new"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["project_overrides"][project_id]["mount_path"] == "/app"
|
|
||||||
|
|
||||||
def test_delete_project_override_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test deleting a project override."""
|
|
||||||
# Create folder with override
|
|
||||||
create_response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "delete-override-test",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
folder_id = create_response.json()["id"]
|
|
||||||
project_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Add override
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-folders/{folder_id}/overrides",
|
|
||||||
json={
|
|
||||||
"project_id": project_id,
|
|
||||||
"mount_path": "/workspace",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete override
|
|
||||||
response = authenticated_client.delete(
|
|
||||||
f"/config-folders/{folder_id}/overrides/{project_id}"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert project_id not in data["project_overrides"]
|
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""Integration tests for SSE endpoint and lifecycle event flow."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Generator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api import events as events_module
|
||||||
|
from src.auth.session import decode_session_cookie
|
||||||
|
from src.config import Settings
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.instance_event import InstanceEvent
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def event_bus() -> Generator[InstanceEventBus, None, None]:
|
||||||
|
bus = InstanceEventBus()
|
||||||
|
bus._reset_for_testing()
|
||||||
|
yield bus
|
||||||
|
bus._reset_for_testing()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_payload() -> InstanceEventPayload:
|
||||||
|
return {
|
||||||
|
"event": "instance.started",
|
||||||
|
"instance_id": str(uuid.uuid4()),
|
||||||
|
"status": "starting",
|
||||||
|
"message": "Container starting...",
|
||||||
|
"metadata": {},
|
||||||
|
"timestamp": "2026-05-28T12:00:00Z",
|
||||||
|
"correlation_id": str(uuid.uuid4()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_id_from_client(client: TestClient) -> uuid.UUID | None:
|
||||||
|
settings = Settings()
|
||||||
|
cookie = client.cookies.get("session")
|
||||||
|
if not cookie:
|
||||||
|
return None
|
||||||
|
session = decode_session_cookie(settings=settings, cookie_value=cookie)
|
||||||
|
if session and "user_id" in session:
|
||||||
|
return uuid.UUID(session["user_id"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_sse_requires_auth(test_client: TestClient) -> None:
|
||||||
|
response = test_client.get("/events/stream")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_sse_enforces_connection_limit(authenticated_client: TestClient) -> None:
|
||||||
|
user_id = _get_user_id_from_client(authenticated_client)
|
||||||
|
assert user_id is not None
|
||||||
|
|
||||||
|
events_module._connection_counts[user_id] = events_module.MAX_CONNECTIONS_PER_USER
|
||||||
|
try:
|
||||||
|
response = authenticated_client.get("/events/stream")
|
||||||
|
assert response.status_code == 429
|
||||||
|
finally:
|
||||||
|
events_module._connection_counts.pop(user_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_sse_event_generator_format() -> None:
|
||||||
|
"""Test the SSE endpoint is registered."""
|
||||||
|
from src.api.events import router
|
||||||
|
|
||||||
|
route_paths = [getattr(r, "path", "") for r in router.routes]
|
||||||
|
assert any("/stream" in str(p) for p in route_paths)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_lifecycle_hook_publishes_event_and_persists(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
) -> None:
|
||||||
|
"""Test that the lifecycle hook publishes an event and persists an audit row."""
|
||||||
|
user_id = _get_user_id_from_client(authenticated_client)
|
||||||
|
assert user_id is not None
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-project",
|
||||||
|
description="Test",
|
||||||
|
owner_id=user_id,
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-repo",
|
||||||
|
path="/tmp/test-repo",
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=user_id,
|
||||||
|
remote_url="https://github.com/test/repo.git",
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-tool",
|
||||||
|
display_name="Test Tool",
|
||||||
|
category="other",
|
||||||
|
interface_type="web",
|
||||||
|
requires_port=True,
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="legacy",
|
||||||
|
compose_template="version: '3.8'\nservices:\n app:\n image: alpine\n command: sleep 3600\n",
|
||||||
|
)
|
||||||
|
db_session.add_all([project, repo, tool_type])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-instance",
|
||||||
|
display_name="Test Instance",
|
||||||
|
tool_type_id=tool_type.id,
|
||||||
|
repository_id=repo.id,
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=user_id,
|
||||||
|
status="pending",
|
||||||
|
compose_path="/tmp/test-compose.yml",
|
||||||
|
port=8080,
|
||||||
|
)
|
||||||
|
db_session.add(instance)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
received: list[Any] = []
|
||||||
|
|
||||||
|
def subscriber(payload: InstanceEventPayload) -> None:
|
||||||
|
received.append(payload)
|
||||||
|
|
||||||
|
event_bus.subscribe("instance.created", subscriber)
|
||||||
|
|
||||||
|
from src.services.lifecycle_hooks import publish_lifecycle_event
|
||||||
|
|
||||||
|
await publish_lifecycle_event(
|
||||||
|
event_bus=event_bus,
|
||||||
|
session=db_session,
|
||||||
|
instance=instance,
|
||||||
|
event_type="instance.created",
|
||||||
|
created_by=user_id,
|
||||||
|
status="pending",
|
||||||
|
message="Instance created",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0]["event"] == "instance.created"
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(InstanceEvent).where(InstanceEvent.instance_id == instance.id)
|
||||||
|
)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].event_type == "created"
|
||||||
|
assert rows[0].created_by == user_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_lifecycle_event_persists_audit_row(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
) -> None:
|
||||||
|
"""Test that publishing a lifecycle event persists an audit row."""
|
||||||
|
user_id = _get_user_id_from_client(authenticated_client)
|
||||||
|
assert user_id is not None
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-project",
|
||||||
|
description="Test",
|
||||||
|
owner_id=user_id,
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-repo",
|
||||||
|
path="/tmp/test-repo",
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=user_id,
|
||||||
|
remote_url="https://github.com/test/repo.git",
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-tool-2",
|
||||||
|
display_name="Test Tool 2",
|
||||||
|
category="other",
|
||||||
|
interface_type="web",
|
||||||
|
requires_port=True,
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="legacy",
|
||||||
|
compose_template="version: '3.8'\nservices:\n app:\n image: alpine\n command: sleep 3600\n",
|
||||||
|
)
|
||||||
|
db_session.add_all([project, repo, tool_type])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-instance",
|
||||||
|
display_name="Test Instance",
|
||||||
|
tool_type_id=tool_type.id,
|
||||||
|
repository_id=repo.id,
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=user_id,
|
||||||
|
status="running",
|
||||||
|
compose_path="/tmp/test-compose.yml",
|
||||||
|
port=8080,
|
||||||
|
)
|
||||||
|
db_session.add(instance)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
from src.services.lifecycle_hooks import publish_lifecycle_event
|
||||||
|
|
||||||
|
await publish_lifecycle_event(
|
||||||
|
event_bus=event_bus,
|
||||||
|
session=db_session,
|
||||||
|
instance=instance,
|
||||||
|
event_type="instance.stopped",
|
||||||
|
created_by=user_id,
|
||||||
|
status="stopped",
|
||||||
|
message="Instance stopped",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(InstanceEvent).where(InstanceEvent.instance_id == instance.id)
|
||||||
|
)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].event_type == "stopped"
|
||||||
|
assert rows[0].status == "stopped"
|
||||||
|
assert rows[0].created_by == user_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_event_bus_pubsub(event_bus: InstanceEventBus) -> None:
|
||||||
|
"""Test that the event bus delivers events to subscribers."""
|
||||||
|
received: list[InstanceEventPayload] = []
|
||||||
|
|
||||||
|
def handler(payload: InstanceEventPayload) -> None:
|
||||||
|
received.append(payload)
|
||||||
|
|
||||||
|
event_bus.subscribe("test.event", handler)
|
||||||
|
|
||||||
|
payload: InstanceEventPayload = {
|
||||||
|
"event": "test.event",
|
||||||
|
"instance_id": str(uuid.uuid4()),
|
||||||
|
"status": "running",
|
||||||
|
"message": "Test",
|
||||||
|
"metadata": {},
|
||||||
|
"timestamp": "2026-05-28T12:00:00Z",
|
||||||
|
"correlation_id": str(uuid.uuid4()),
|
||||||
|
}
|
||||||
|
|
||||||
|
asyncio.run(event_bus.publish("test.event", payload))
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0]["event"] == "test.event"
|
||||||
@@ -70,6 +70,20 @@ def test_get_current_branch_handles_unborn_main() -> None:
|
|||||||
assert get_current_branch(tmpdir) == "main"
|
assert get_current_branch(tmpdir) == "main"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_branch_on_bare_repo_with_no_commits() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
|
||||||
|
create_branch(f"{tmpdir}/bare.git", "main")
|
||||||
|
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkout_branch_on_bare_repo_with_no_commits() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
|
||||||
|
checkout_branch(f"{tmpdir}/bare.git", "main")
|
||||||
|
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
|
||||||
|
|
||||||
|
|
||||||
class TestBranchOperations:
|
class TestBranchOperations:
|
||||||
"""Tests for branch management functions."""
|
"""Tests for branch management functions."""
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ def test_base_metadata_collects_declared_tables() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
def test_shared_mixins_define_expected_columns() -> None:
|
def test_shared_mixins_define_expected_columns() -> None:
|
||||||
assert "id" in UUIDPrimaryKeyMixin.__dict__
|
assert "id" in UUIDPrimaryKeyMixin.__dict__
|
||||||
assert "created_at" in TimestampMixin.__dict__
|
assert "created_at" in TimestampMixin.__dict__
|
||||||
@@ -24,20 +23,26 @@ def test_shared_mixins_define_expected_columns() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
def test_expected_tables_are_registered() -> None:
|
def test_expected_tables_are_registered() -> None:
|
||||||
assert set(Base.metadata.tables) == {
|
assert set(Base.metadata.tables) == {
|
||||||
"refresh_tokens",
|
"config_profile_includes",
|
||||||
|
"config_profiles",
|
||||||
"git_repositories",
|
"git_repositories",
|
||||||
|
"health_checks",
|
||||||
|
"instance_events",
|
||||||
|
"notifications",
|
||||||
"projects",
|
"projects",
|
||||||
"ssh_keys",
|
"ssh_keys",
|
||||||
|
"terminal_sessions",
|
||||||
|
"tool_definition_manifests",
|
||||||
|
"tool_instances",
|
||||||
|
"tool_types",
|
||||||
"user_configs",
|
"user_configs",
|
||||||
"users",
|
"users",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
def test_user_table_has_required_columns() -> None:
|
def test_user_table_has_required_columns() -> None:
|
||||||
columns = User.__table__.columns
|
columns = User.__table__.columns
|
||||||
|
|
||||||
@@ -56,7 +61,6 @@ def test_user_table_has_required_columns() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
|
def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
|
||||||
owner_fk = next(iter(Project.__table__.c.owner_id.foreign_keys))
|
owner_fk = next(iter(Project.__table__.c.owner_id.foreign_keys))
|
||||||
ssh_fk = next(iter(Project.__table__.c.default_ssh_key_id.foreign_keys))
|
ssh_fk = next(iter(Project.__table__.c.default_ssh_key_id.foreign_keys))
|
||||||
@@ -68,7 +72,6 @@ def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
def test_repository_and_user_config_relationships_are_registered() -> None:
|
def test_repository_and_user_config_relationships_are_registered() -> None:
|
||||||
project_fk = next(iter(GitRepository.__table__.c.project_id.foreign_keys))
|
project_fk = next(iter(GitRepository.__table__.c.project_id.foreign_keys))
|
||||||
owner_fk = next(iter(GitRepository.__table__.c.owner_id.foreign_keys))
|
owner_fk = next(iter(GitRepository.__table__.c.owner_id.foreign_keys))
|
||||||
@@ -82,33 +85,15 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
|
|||||||
assert UserConfig.user.property.mapper.class_ is User
|
assert UserConfig.user.property.mapper.class_ is User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
|
|
||||||
def test_refresh_token_table_has_required_columns_and_relationships() -> None:
|
|
||||||
columns = RefreshToken.__table__.columns
|
|
||||||
user_fk = next(iter(RefreshToken.__table__.c.user_id.foreign_keys))
|
|
||||||
|
|
||||||
assert set(columns.keys()) == {
|
|
||||||
"id",
|
|
||||||
"user_id",
|
|
||||||
"token_hash",
|
|
||||||
"expires_at",
|
|
||||||
"revoked_at",
|
|
||||||
"user_agent",
|
|
||||||
"ip_address",
|
|
||||||
"created_at",
|
|
||||||
}
|
|
||||||
assert columns["token_hash"].unique is True
|
|
||||||
assert columns["revoked_at"].nullable is True
|
|
||||||
assert user_fk.target_fullname == "users.id"
|
|
||||||
assert RefreshToken.user.property.mapper.class_ is User
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
async def test_async_session_can_insert_and_load_user(db_session: AsyncSession) -> None:
|
async def test_async_session_can_insert_and_load_user(db_session: AsyncSession) -> None:
|
||||||
user = User(email="dev@headquarter.local", name="Dev User", authentik_id="dev-user", avatar_url=None)
|
user = User(
|
||||||
|
email="dev@headquarter.local",
|
||||||
|
name="Dev User",
|
||||||
|
authentik_id="dev-user",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
|
||||||
db_session.add(user)
|
db_session.add(user)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""Integration tests for notifications API."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.user import User
|
||||||
|
from src.models.user_config import UserConfig
|
||||||
|
from src.services.notification_service import NotificationService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def notification_service() -> NotificationService:
|
||||||
|
return NotificationService()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_a(db_session: AsyncSession) -> User:
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="user-a@headquarter.local",
|
||||||
|
name="User A",
|
||||||
|
authentik_id=f"authentik-{uuid.uuid4()}",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_b(db_session: AsyncSession) -> User:
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="user-b@headquarter.local",
|
||||||
|
name="User B",
|
||||||
|
authentik_id=f"authentik-{uuid.uuid4()}",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _mint_cookie_for_user(test_client: TestClient, user_id: uuid.UUID) -> None:
|
||||||
|
from src.auth.session import create_session_cookie
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
cookie = create_session_cookie(
|
||||||
|
settings=settings,
|
||||||
|
user_id=str(user_id),
|
||||||
|
)
|
||||||
|
test_client.cookies.set("session", cookie)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_list_requires_auth(test_client: TestClient) -> None:
|
||||||
|
response = test_client.get("/notifications")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_list_returns_only_own_notifications(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
user_b: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_notifications() -> None:
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="A"
|
||||||
|
)
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session, user_b.id, category="instance", severity="info", title="B"
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(create_notifications())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.get("/notifications")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["items"]) == 1
|
||||||
|
assert data["items"][0]["title"] == "A"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_list_pagination(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_many() -> None:
|
||||||
|
for i in range(25):
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title=f"Notification {i}",
|
||||||
|
)
|
||||||
|
n.created_at = datetime.now(timezone.utc) - timedelta(seconds=i)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(n)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(create_many())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.get("/notifications?limit=10&offset=10")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["items"]) == 10
|
||||||
|
assert data["total"] == 25
|
||||||
|
assert data["limit"] == 10
|
||||||
|
assert data["offset"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_unread_count_endpoint(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_unread() -> None:
|
||||||
|
for _ in range(3):
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="Unread",
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(create_unread())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.get("/notifications/unread")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["count"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_mark_read_endpoint(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_and_get() -> uuid.UUID:
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="To read"
|
||||||
|
)
|
||||||
|
return n.id
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
nid = asyncio.run(create_and_get())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.patch(f"/notifications/{nid}/read")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["read_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_mark_read_404_for_other_user(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
user_b: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_and_get() -> uuid.UUID:
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="Owned by A",
|
||||||
|
)
|
||||||
|
return n.id
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
nid = asyncio.run(create_and_get())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_b.id)
|
||||||
|
response = authenticated_client.patch(f"/notifications/{nid}/read")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_mark_all_read_endpoint(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_unread() -> None:
|
||||||
|
for _ in range(4):
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="Unread",
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(create_unread())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.post("/notifications/mark-all-read")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["marked_count"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_dismiss_endpoint(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_and_get() -> uuid.UUID:
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="To dismiss",
|
||||||
|
)
|
||||||
|
return n.id
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
nid = asyncio.run(create_and_get())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.delete(f"/notifications/{nid}")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
response = authenticated_client.get("/notifications")
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_dismiss_404_for_other_user(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
user_b: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_and_get() -> uuid.UUID:
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="Owned by A",
|
||||||
|
)
|
||||||
|
return n.id
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
nid = asyncio.run(create_and_get())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_b.id)
|
||||||
|
response = authenticated_client.delete(f"/notifications/{nid}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_mute_categories_filter_in_list(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
async def setup() -> None:
|
||||||
|
config = UserConfig(
|
||||||
|
user_id=user_a.id, config={"notification_mute_categories": ["instance"]}
|
||||||
|
)
|
||||||
|
db_session.add(config)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="Instance",
|
||||||
|
)
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="system", severity="info", title="System"
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(setup())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.get("/notifications")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["items"]) == 1
|
||||||
|
assert data["items"][0]["title"] == "System"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user