Compare commits

..

2 Commits

Author SHA1 Message Date
alex 22c035984e feat: add config profiles data model and migrations
- Add ConfigProfile model with user ownership, name, description
- Add ConfigInclude model for ordered profile self-references
- Add ConfigMount model for mount/file definitions
- Add selected_profile_id to ToolInstance for per-instance profile selection
- Add default profile properties to UserConfig JSONB config
- Create Alembic migration 0013 for new tables and columns
- Register new models in models/__init__.py
- Mark config_folders.is_active as deprecated
- Add migration metadata test

Quality gates: syntax check passed (all files parse successfully)
OpenSpec: add-config-profiles task 1.1
2026-05-24 12:54:45 +00:00
alex d35037df01 docs: add OpenSpec status review and update add-config-profiles tasks
- Create comprehensive OpenSpec status and implementation checklist review
- Document current state: 11 active changes, 66/345 tasks complete (19.1%)
- Update add-config-profiles/tasks.md to reflect completed model work
- Identify near-completion changes, blockers, and recommendations

Quality gates: review document only, no code changes
2026-05-24 12:54:23 +00:00
672 changed files with 9513 additions and 85705 deletions
-3
View File
@@ -1,3 +0,0 @@
{
"fingerprint": "c36b11ec5edebc02aa51b1113a7a11dc2559e812"
}
-35
View File
@@ -1,35 +0,0 @@
# 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`.
-195
View File
@@ -1,195 +0,0 @@
---
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
-5
View File
@@ -48,8 +48,3 @@ apps/web/dist/
# OS
.DS_Store
Thumbs.db
# Local runtime state
.atl/
.pi/
swap-pane
-1
View File
@@ -1 +0,0 @@
{}
@@ -1,10 +0,0 @@
{
"sessionID": "ses_1da2608b1ffergOzow3NQt1mGr",
"updatedAt": "2026-05-15T23:50:42.832Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-05-15T23:50:42.832Z"
}
}
}
-2
View File
@@ -1,2 +0,0 @@
262629
1779624255076
-6
View File
@@ -1,6 +0,0 @@
# Runtime data
*.db
*.db-journal
*.db-wal
*.db-shm
daemon-state.json
-20
View File
@@ -1,20 +0,0 @@
# 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
-43
View File
@@ -1,43 +0,0 @@
{"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
-7
View File
@@ -85,13 +85,6 @@ Before completion, report:
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
### Auto-commit on spec completion
-1
View File
@@ -21,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **User Settings** - Theme selection, git identity, and preference management
- **SSH Key Management** - Ed25519 key generation with secure storage
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
- **Config Profiles** - User-owned profile CRUD with includes, mounts, path validation, cycle detection, and default profile selection
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
### Changed
-568
View File
@@ -1,568 +0,0 @@
{
"version": "v2",
"timestamp": 1779889907001,
"ruleHash": "fd9b2b15f2ac8993",
"queries": [
{
"id": "bare-except",
"name": "Bare Except Clause",
"severity": "warning",
"language": "python",
"message": "Bare 'except:' clause — catches SystemExit, KeyboardInterrupt",
"query": " (except_clause\n \"except\") @CLAUSE",
"metavars": [
"CLAUSE"
],
"post_filter": "bare_except_only",
"defect_class": "silent-error",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/bare-except.yml"
},
{
"id": "eval-exec",
"name": "Eval/Exec Usage",
"severity": "warning",
"language": "python",
"message": "{{FUNC}}() detected — security risk, code injection vulnerability",
"query": " (call\n function: (identifier) @FUNC\n (#match? @FUNC \"^(eval|exec)$\")\n arguments: (argument_list) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/eval-exec.yml"
},
{
"id": "exit-signature-check",
"name": "__exit__ Missing Parameters",
"severity": "error",
"language": "python",
"message": "__exit__ should accept type, value, and traceback arguments",
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__exit__\")\n parameters: (parameters\n (_) @SELF\n . (_) @PARAM1?\n . (_) @PARAM2?\n . (_) @PARAM3?))",
"metavars": [
"NAME",
"SELF",
"PARAM1",
"PARAM2",
"PARAM3"
],
"post_filter": "exit_params_insufficient",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/exit-signature-check.yml"
},
{
"id": "in-operator-unsupported",
"name": "In and Not In Operators Should Be Used on Valid Objects",
"severity": "warning",
"language": "python",
"message": "'in' operator used on object that may not support containment",
"query": " (comparison_operator\n (identifier) @OBJ\n \"in\"\n (identifier) @TARGET)\n (comparison_operator\n (identifier) @OBJ\n \"not\"\n \"in\"\n (identifier) @TARGET)",
"metavars": [
"OBJ",
"TARGET"
],
"post_filter": "check_in_operator_types",
"defect_class": "correctness",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/in-operator-unsupported.yml"
},
{
"id": "is-vs-equals",
"name": "Is vs Equals for Literals",
"severity": "warning",
"language": "python",
"message": "Using 'is' with literal — use '==' for value comparison",
"query": " (comparison_operator\n (identifier)\n (\"is\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is\")\n (integer) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (integer) @LITERAL)",
"metavars": [
"LITERAL"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/is-vs-equals.yml"
},
{
"id": "iter-return-iterator",
"name": "__iter__ Should Return Iterator",
"severity": "warning",
"language": "python",
"message": "__iter__ should return an iterator (object with __next__ method)",
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__iter__\")\n body: (block\n (return_statement) @RETURN))",
"metavars": [
"NAME",
"RETURN"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/iter-return-iterator.yml"
},
{
"id": "mutable-default-arg",
"name": "Mutable Default Argument",
"severity": "warning",
"language": "python",
"message": "Mutable default argument — list/dict/set as default value",
"query": " (function_definition\n (parameters\n (default_parameter\n (identifier) @PARAM\n [(list) (dictionary) (set)] @MUTABLE)))",
"metavars": [
"PARAM",
"MUTABLE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/mutable-default-arg.yml"
},
{
"id": "no-super-torchscript",
"name": "super Should Not Be Used in TorchScript Methods",
"severity": "error",
"language": "python",
"message": "super() calls should not be used in TorchScript methods",
"query": " (function_definition\n (decorator\n (call\n function: (identifier) @DEC (#match? @DEC \"^(torch\\.jit\\.script|jit\\.script)$\")))\n body: (block\n (call\n function: (identifier) @FUNC (#eq? @FUNC \"super\")) @CALL))",
"metavars": [
"DEC",
"FUNC",
"CALL"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/no-super-torchscript.yml"
},
{
"id": "notimplemented-boolean-context",
"name": "NotImplemented in Boolean Context",
"severity": "error",
"language": "python",
"message": "NotImplemented should not be used in boolean contexts",
"query": " (if_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (while_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (binary_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\")\n (\"and\" | \"or\"))\n (boolean_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (unary_operator\n operator: (\"not\")\n argument: (identifier) @COND (#eq? @COND \"NotImplemented\"))",
"metavars": [
"COND"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml"
},
{
"id": "python-assert-production",
"name": "Assert in Production Code",
"severity": "warning",
"language": "python",
"message": "assert statement stripped by Python -O flag — use explicit checks with exceptions in production code",
"query": " (assert_statement) @ASSERT",
"metavars": [
"ASSERT"
],
"defect_class": "correctness",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-assert-production.yml"
},
{
"id": "python-command-injection",
"name": "Command Injection Sink",
"severity": "error",
"language": "python",
"message": "Potential command injection sink — avoid shell execution with dynamic input",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"os\")\n (#match? @FN \"^(system|popen)$\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true)))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
"metavars": [
"MOD",
"FN",
"ARGS",
"KW"
],
"post_filter": "py_command_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-command-injection.yml"
},
{
"id": "python-cross-language-method",
"name": "Cross-Language Method Leakage",
"severity": "warning",
"language": "python",
"message": "'{METHOD}' is not a Python method — likely a {LANG} idiom leaking in",
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @METHOD)\n (#match? @METHOD \"^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$\"))",
"metavars": [
"OBJ",
"METHOD"
],
"post_filter": "match_captures",
"post_filter_params": {
"METHOD": "^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$"
},
"defect_class": "hallucination",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml"
},
{
"id": "python-debugger",
"name": "Debugger Statement",
"severity": "warning",
"language": "python",
"message": "Debugger call '{{FUNC}}' — remove before committing",
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"breakpoint\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n (#eq? @MOD \"pdb\")\n (#match? @FUNC \"^(set_trace|post_mortem|pm|run|runcall)$\"))",
"metavars": [
"FUNC",
"MOD"
],
"defect_class": "safety",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-debugger.yml"
},
{
"id": "python-empty-except",
"name": "Empty Except Block",
"severity": "warning",
"language": "python",
"message": "Except block only contains 'pass' — handle or re-raise the exception",
"query": " (try_statement\n (except_clause\n body: (block) @BODY))",
"metavars": [
"BODY"
],
"post_filter": "python_empty_except",
"defect_class": "silent-error",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-empty-except.yml"
},
{
"id": "python-hallucinated-import",
"name": "Hallucinated Import",
"severity": "warning",
"language": "python",
"message": "Hallucinated import — '{NAME}' does not exist in '{MODULE}'",
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n name: (dotted_name) @NAME)",
"metavars": [
"MODULE",
"NAME"
],
"post_filter": "match_captures",
"post_filter_params": {
"MODULE": "^(requests|flask|django|typing|collections|asyncio|json|unittest|pytest|urllib|sqlalchemy)$",
"NAME": "^(JSONResponse|HTMLResponse|RedirectResponse|StreamingResponse|Depends|Query|Path|Body|Header|Cookie|Form|File|UploadFile|FastAPI|APIRouter|HTTPException|BackgroundTasks|dataclass|fields|BaseModel|Field|validator|aiohttp|parse|stringify|fixture|TestCase|get|post|put|delete|Model|Session|Column|Integer|String)$"
},
"defect_class": "hallucination",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml"
},
{
"id": "python-hardcoded-secrets",
"name": "Hardcoded Secret",
"severity": "warning",
"language": "python",
"message": "Hardcoded {{VARNAME}} — use environment variables or a secrets manager",
"query": " (assignment\n left: (identifier) @VARNAME\n right: (string) @VALUE)",
"metavars": [
"VARNAME",
"VALUE"
],
"post_filter": "check_secret_pattern",
"defect_class": "secrets",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hardcoded-secrets.yml"
},
{
"id": "python-insecure-deserialization",
"name": "Insecure Deserialization",
"severity": "error",
"language": "python",
"message": "Potential insecure deserialization sink — avoid unsafe loaders",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list (_) @DATA)\n (#match? @MOD \"^(pickle|yaml)$\")\n (#match? @FN \"^(load|loads|unsafe_load)$\"))",
"metavars": [
"MOD",
"FN",
"DATA"
],
"post_filter": "py_insecure_deserialization_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-deserialization.yml"
},
{
"id": "python-insecure-random",
"name": "Insecure Randomness",
"severity": "warning",
"language": "python",
"message": "Insecure randomness source detected — use secrets or os.urandom for security-sensitive values",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"random\")\n (#match? @FN \"^(random|randint|randrange|choice|choices)$\"))",
"metavars": [
"MOD",
"FN",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-random.yml"
},
{
"id": "python-mutable-class-attr",
"name": "Mutable Class Attribute",
"severity": "warning",
"language": "python",
"message": "Class attribute '{{VARNAME}}' is mutable — shared across all instances",
"query": " (class_definition\n body: (block\n (expression_statement\n (assignment\n left: (identifier) @VARNAME\n right: [\n (list) @VALUE\n (dictionary) @VALUE\n (set) @VALUE\n ]))))",
"metavars": [
"VARNAME",
"VALUE"
],
"post_filter": "not_in_function",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-mutable-class-attr.yml"
},
{
"id": "python-path-traversal",
"name": "Path Traversal Risk",
"severity": "warning",
"language": "python",
"message": "Potential path traversal sink — sanitize and constrain file paths",
"query": " [\n (call\n function: (identifier) @FN\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n ]\n (#match? @FN \"^(open|read_text|read_bytes|write_text|write_bytes|remove|unlink|rmdir)$\")",
"metavars": [
"MOD",
"FN",
"PATH"
],
"post_filter": "py_path_traversal_sink",
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-path-traversal.yml"
},
{
"id": "python-print-statement",
"name": "Print Statement in Production",
"severity": "warning",
"language": "python",
"message": "print() — remove debug output before committing",
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"print\")\n arguments: (argument_list) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"post_filter": "not_in_test_block",
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-print-statement.yml"
},
{
"id": "python-raise-string",
"name": "Raise String Instead of Exception",
"severity": "warning",
"language": "python",
"message": "raise with string literal — Python 3 requires exception instances",
"query": " (raise_statement\n (string) @VALUE)",
"metavars": [
"VALUE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-raise-string.yml"
},
{
"id": "python-sleep-in-test",
"name": "time.sleep in Test",
"severity": "warning",
"language": "python",
"message": "time.sleep() in test — use synchronisation primitives or polling helpers instead of fixed sleeps",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n (#eq? @MOD \"time\")\n (#eq? @FN \"sleep\")) @CALL",
"metavars": [
"MOD",
"FN",
"CALL"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sleep-in-test.yml"
},
{
"id": "python-sql-injection",
"name": "SQL Injection Risk",
"severity": "error",
"language": "python",
"message": "Potential SQL injection sink — use parameterized queries",
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(binary_operator) (identifier) (call)] @SQL\n (_)*))",
"metavars": [
"OBJ",
"FN",
"SQL"
],
"post_filter": "py_sql_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml"
},
{
"id": "python-ssrf",
"name": "SSRF Risk",
"severity": "warning",
"language": "python",
"message": "Potential SSRF sink — validate/allowlist outbound URLs",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (subscript) (call)] @URL)\n (#eq? @MOD \"requests\")\n (#match? @FN \"^(get|post|put|patch|delete|request|head|options)$\"))",
"metavars": [
"MOD",
"FN",
"URL"
],
"post_filter": "py_ssrf_sink",
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-ssrf.yml"
},
{
"id": "python-subprocess-shell",
"name": "subprocess with shell=True",
"severity": "warning",
"language": "python",
"message": "subprocess called with shell=True — command injection risk if any argument is user-controlled",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true) @VAL))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
"metavars": [
"MOD",
"FN",
"KW"
],
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-subprocess-shell.yml"
},
{
"id": "python-thread-global-write",
"name": "Threaded Shared State Risk",
"severity": "warning",
"language": "python",
"message": "Thread creation detected — ensure shared state mutations are synchronized",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS)\n (#eq? @MOD \"threading\")\n (#eq? @FN \"Thread\")",
"metavars": [
"MOD",
"FN",
"ARGS"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-thread-global-write.yml"
},
{
"id": "python-unsafe-regex",
"name": "Unsafe Dynamic Regex",
"severity": "warning",
"language": "python",
"message": "re.{{FUNC}}() with variable pattern — ReDoS risk if pattern is user-controlled",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n arguments: (argument_list\n (identifier) @PATTERN)\n (#eq? @MOD \"re\")\n (#match? @FUNC \"^(compile|match|search|fullmatch|findall|finditer|sub|subn|split)$\"))",
"metavars": [
"MOD",
"FUNC",
"PATTERN"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-unsafe-regex.yml"
},
{
"id": "python-weak-hash",
"name": "Weak Hash Primitive",
"severity": "error",
"language": "python",
"message": "Weak hash primitive detected (MD5/SHA1) — use SHA-256+ for security-sensitive contexts",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"hashlib\")\n (#match? @FN \"^(md5|sha1)$\"))",
"metavars": [
"MOD",
"FN",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-weak-hash.yml"
},
{
"id": "return-in-generator",
"name": "Return with Value in Generator",
"severity": "error",
"language": "python",
"message": "'return' with a value should not be used in a generator function",
"query": " (function_definition\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN)) @FUNCTION",
"metavars": [
"FUNCTION",
"RETURN",
"RETURN_VAL"
],
"post_filter": "is_generator_with_valued_return",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-generator.yml"
},
{
"id": "return-in-init",
"name": "Return Value in __init__",
"severity": "error",
"language": "python",
"message": "__init__ should not return a value — it must always return None",
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__init__\")\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN))",
"metavars": [
"NAME",
"RETURN",
"RETURN_VAL"
],
"post_filter": "has_return_value",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-init.yml"
},
{
"id": "send-file-mimetype",
"name": "send_file Should Specify Mimetype or Download Name",
"severity": "error",
"language": "python",
"message": "send_file should specify 'mimetype' or 'download_name' when used with file-like objects",
"query": " (call\n function: (identifier) @FUNC (#eq? @FUNC \"send_file\")\n arguments: (argument_list\n (_) @FIRST_ARG\n (keyword_argument)? @KW))",
"metavars": [
"FUNC",
"FIRST_ARG",
"KW"
],
"post_filter": "missing_mimetype_and_download_name",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/send-file-mimetype.yml"
},
{
"id": "unreachable-except",
"name": "Unreachable Except Clause",
"severity": "warning",
"language": "python",
"message": "Unreachable except clause — earlier except catches all",
"query": " (try_statement\n (except_clause\n \"except\") @GENERAL\n (except_clause\n \"except\"\n (identifier) @SPECIFIC))",
"metavars": [
"GENERAL",
"SPECIFIC"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/unreachable-except.yml"
},
{
"id": "wildcard-import",
"name": "Wildcard Import",
"severity": "warning",
"language": "python",
"message": "Wildcard import — pollutes namespace, hard to track origin",
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n (wildcard_import) @WILDCARD)",
"metavars": [
"MODULE",
"WILDCARD"
],
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/wildcard-import.yml"
},
{
"id": "yield-return-outside-function",
"name": "Yield/Return Outside Function",
"severity": "error",
"language": "python",
"message": "{{STATEMENT}} used outside function — syntax error",
"query": " (module\n (expression_statement\n (yield) @STATEMENT))\n (module\n (expression_statement\n (yield_expression) @STATEMENT))\n (module\n (return_statement) @STATEMENT)",
"metavars": [
"STATEMENT"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/yield-return-outside-function.yml"
}
]
}
@@ -1,29 +0,0 @@
"""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")
@@ -1,119 +0,0 @@
"""add profile resolver fields to config profiles and mounts
Revision ID: 0014_add_profile_resolver_fields
Revises: 0013_add_config_profiles
Create Date: 2026-05-24 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0014_add_profile_resolver_fields"
down_revision: Union[str, None] = "0013_add_config_profiles"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add fields to config_profiles
op.add_column(
"config_profiles",
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("environment_variables", sa.JSON(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("start_command", sa.Text(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("working_directory", sa.Text(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("port", sa.Integer(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
)
# Add foreign keys for project and tool_type
op.create_foreign_key(
"fk_config_profiles_project",
"config_profiles",
"projects",
["project_id"],
["id"],
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_config_profiles_tool_type",
"config_profiles",
"tool_types",
["tool_type_id"],
["id"],
ondelete="CASCADE",
)
# Create indices
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
# Alter config_mounts: rename mount_path to target_path, add mode, change content to files JSON
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
op.add_column(
"config_mounts",
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
)
op.add_column(
"config_mounts",
sa.Column("files", sa.JSON(), nullable=True),
)
# Drop the source_profile foreign key if it exists
op.drop_constraint(
"config_mounts_source_profile_id_fkey",
"config_mounts",
type_="foreignkey",
)
op.drop_column("config_mounts", "content")
op.drop_column("config_mounts", "source_profile_id")
def downgrade() -> None:
# Restore config_mounts
op.add_column(
"config_mounts",
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_mounts",
sa.Column("content", sa.Text(), nullable=True),
)
op.drop_column("config_mounts", "files")
op.drop_column("config_mounts", "mode")
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
# Restore config_profiles
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
op.drop_constraint("fk_config_profiles_tool_type", "config_profiles", type_="foreignkey")
op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey")
op.drop_column("config_profiles", "is_default")
op.drop_column("config_profiles", "port")
op.drop_column("config_profiles", "working_directory")
op.drop_column("config_profiles", "start_command")
op.drop_column("config_profiles", "environment_variables")
op.drop_column("config_profiles", "tool_type_id")
op.drop_column("config_profiles", "project_id")
@@ -1,23 +0,0 @@
"""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
@@ -1,109 +0,0 @@
"""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')
@@ -1,32 +0,0 @@
"""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")
@@ -1,129 +0,0 @@
"""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'")
)
@@ -1,36 +0,0 @@
"""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')
@@ -1,25 +0,0 @@
"""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'))
@@ -1,30 +0,0 @@
"""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")
@@ -1,86 +0,0 @@
"""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")
@@ -1,28 +0,0 @@
"""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")
@@ -1,41 +0,0 @@
"""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)")
@@ -1,122 +0,0 @@
"""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")
@@ -1,61 +0,0 @@
"""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")
@@ -1,373 +0,0 @@
"""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")
@@ -1,89 +0,0 @@
"""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"),
)
@@ -1,69 +0,0 @@
"""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")
@@ -1,27 +0,0 @@
"""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")
@@ -1,32 +0,0 @@
"""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,
),
)
@@ -1,54 +0,0 @@
"""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
@@ -1,148 +0,0 @@
"""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
@@ -1,140 +0,0 @@
"""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
@@ -1,121 +0,0 @@
"""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
@@ -1,105 +0,0 @@
"""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,
},
)
@@ -1,81 +0,0 @@
"""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")
@@ -1,23 +0,0 @@
"""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
@@ -1,23 +0,0 @@
"""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
+95 -7
View File
@@ -4,23 +4,111 @@ import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import Field
from pydantic import BaseModel, Field, field_validator
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"])
MAX_FOLDER_SIZE_MB = 10
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
class ConfigFolderCreate(BaseModel):
name: str = Field(description="Folder name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
mount_path: str = Field(description="Default mount path in container")
files: dict = Field(default_factory=dict, description="Files as {path: content}")
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
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_FOLDER_SIZE_BYTES:
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
return v
class ConfigFolderUpdate(BaseModel):
name: str | None = Field(default=None, description="Folder name")
description: str | None = Field(default=None, description="Optional description")
mount_path: str | None = Field(default=None, description="Default mount path")
files: dict | None = Field(default=None, description="Files as {path: content}")
is_active: bool | None = Field(default=None, description="Active/inactive toggle")
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
@field_validator("files")
@classmethod
def validate_files(cls, v: dict | None) -> dict | None:
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_FOLDER_SIZE_BYTES:
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
return v
class ProjectOverrideCreate(BaseModel):
mount_path: str | None = Field(default=None, description="Override mount path")
files: dict = Field(default_factory=dict, description="Override files")
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
class ConfigFolderResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
mount_path: str
files: dict
project_overrides: dict | None
is_active: bool
created_at: str
updated_at: str
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
async def list_config_folders(
-299
View File
@@ -1,299 +0,0 @@
"""Config profile API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile
from src.models.tool_type import ToolType
from src.models.user_config import UserConfig
from src.schemas.config_profile import (
ConfigIncludeCreate,
ConfigIncludeUpdate,
ConfigMountCreate,
ConfigMountUpdate,
ConfigProfileCreate,
ConfigProfileUpdate,
DefaultProfilesUpdate,
)
from src.services.config_profiles import (
check_duplicate_include,
check_duplicate_mount_path,
check_duplicate_name,
get_default_profile_for_tool_type,
get_default_profiles,
get_owned_profile,
include_to_dict,
list_includes_for_profile,
list_mounts_for_profile,
mount_to_dict,
profile_to_dict,
set_default_profiles,
validate_includes_no_cycle,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
@router.get("")
async def list_config_profiles(
tool_type_id: str | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
if tool_type_id:
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
result = await session.execute(query.order_by(ConfigProfile.name))
return {"profiles": [profile_to_dict(p) for p in result.scalars().all()]}
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_config_profile(
data: ConfigProfileCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
await check_duplicate_name(session, user_id, data.name)
profile = ConfigProfile(user_id=user_id, name=data.name, description=data.description)
session.add(profile)
await session.commit()
await session.refresh(profile)
return profile_to_dict(profile)
@router.get("/defaults")
async def get_default_profiles_endpoint(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
return await get_default_profiles(session, user_id)
@router.put("/defaults")
async def set_default_profiles_endpoint(
data: DefaultProfilesUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
return await set_default_profiles(session, user_id, data.default_profiles)
@router.get("/defaults/{tool_type_id}")
async def get_default_profile_for_tool_type_endpoint(
tool_type_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
return await get_default_profile_for_tool_type(session, user_id, tool_type_id)
@router.get("/{profile_id}")
async def get_config_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
profile = await session.get(
ConfigProfile,
profile_id,
options=[selectinload(ConfigProfile.includes), selectinload(ConfigProfile.mounts)],
)
if profile is None or profile.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found")
includes_data = []
for inc in profile.includes:
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
return {
**profile_to_dict(profile),
"includes": includes_data,
"mounts": [mount_to_dict(m) for m in profile.mounts],
}
@router.put("/{profile_id}")
async def update_config_profile(
profile_id: uuid.UUID,
data: ConfigProfileUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
profile = await get_owned_profile(profile_id, user_id, session)
if data.name is not None:
await check_duplicate_name(session, user_id, data.name, exclude_id=profile_id)
profile.name = data.name
if data.description is not None:
profile.description = data.description
await session.commit()
await session.refresh(profile)
return profile_to_dict(profile)
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
profile = await get_owned_profile(profile_id, user_id, session)
await session.delete(profile)
await session.commit()
@router.get("/{profile_id}/includes")
async def list_profile_includes(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
await get_owned_profile(profile_id, user_id, session)
return await list_includes_for_profile(session, profile_id)
@router.post("/{profile_id}/includes", status_code=status.HTTP_201_CREATED)
async def add_profile_include(
profile_id: uuid.UUID,
data: ConfigIncludeCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
profile = await get_owned_profile(profile_id, user_id, session)
included_profile_id = uuid.UUID(data.included_profile_id)
if included_profile_id == profile_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="a profile cannot include itself")
included_profile = await session.get(ConfigProfile, included_profile_id)
if included_profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="included profile not found")
if included_profile.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="included profile does not belong to user")
await check_duplicate_include(session, profile_id, included_profile_id)
await validate_includes_no_cycle(session, profile_id, included_profile_id)
include = ConfigInclude(
profile_id=profile_id,
included_profile_id=included_profile_id,
order_index=data.order_index,
)
session.add(include)
await session.commit()
await session.refresh(include)
return include_to_dict(include, included_profile.name)
@router.put("/{profile_id}/includes/{include_id}")
async def update_profile_include(
profile_id: uuid.UUID,
include_id: uuid.UUID,
data: ConfigIncludeUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
await get_owned_profile(profile_id, user_id, session)
include = await session.get(ConfigInclude, include_id)
if include is None or include.profile_id != profile_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
include.order_index = data.order_index
await session.commit()
await session.refresh(include)
included_profile = await session.get(ConfigProfile, include.included_profile_id)
return include_to_dict(include, included_profile.name if included_profile else None)
@router.delete("/{profile_id}/includes/{include_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_profile_include(
profile_id: uuid.UUID,
include_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
await get_owned_profile(profile_id, user_id, session)
include = await session.get(ConfigInclude, include_id)
if include is None or include.profile_id != profile_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
await session.delete(include)
await session.commit()
@router.get("/{profile_id}/mounts")
async def list_profile_mounts(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
await get_owned_profile(profile_id, user_id, session)
return await list_mounts_for_profile(session, profile_id)
@router.post("/{profile_id}/mounts", status_code=status.HTTP_201_CREATED)
async def add_profile_mount(
profile_id: uuid.UUID,
data: ConfigMountCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
profile = await get_owned_profile(profile_id, user_id, session)
await check_duplicate_mount_path(session, profile_id, data.target_path)
mount = ConfigMount(
profile_id=profile_id,
target_path=data.target_path,
mode=data.mode,
files=data.files,
order_index=data.order_index,
)
session.add(mount)
await session.commit()
await session.refresh(mount)
return mount_to_dict(mount)
@router.put("/{profile_id}/mounts/{mount_id}")
async def update_profile_mount(
profile_id: uuid.UUID,
mount_id: uuid.UUID,
data: ConfigMountUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
await get_owned_profile(profile_id, user_id, session)
mount = await session.get(ConfigMount, mount_id)
if mount is None or mount.profile_id != profile_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
if data.target_path is not None:
await check_duplicate_mount_path(session, profile_id, data.target_path, exclude_id=mount_id)
mount.target_path = data.target_path
if data.files is not None:
mount.files = data.files
if data.order_index is not None:
mount.order_index = data.order_index
await session.commit()
await session.refresh(mount)
return mount_to_dict(mount)
@router.delete("/{profile_id}/mounts/{mount_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_profile_mount(
profile_id: uuid.UUID,
mount_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
await get_owned_profile(profile_id, user_id, session)
mount = await session.get(ConfigMount, mount_id)
if mount is None or mount.profile_id != profile_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
await session.delete(mount)
await session.commit()
-80
View File
@@ -1,80 +0,0 @@
"""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",
},
)
File diff suppressed because it is too large Load Diff
+40 -7
View File
@@ -5,17 +5,11 @@ from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, status
from pydantic import BaseModel, Field
from sqlalchemy import text
from src.config import Settings
from src.database import SessionLocal
from src.schemas.health import (
DatabaseHealth,
DatabaseHealthResponse,
DiskHealth,
HealthChecks,
HealthResponse,
)
router = APIRouter()
@@ -23,6 +17,45 @@ router = APIRouter()
_start_time = time.time()
class DatabaseHealth(BaseModel):
"""Database health check result."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
class DiskHealth(BaseModel):
"""Disk space health check result."""
status: str = Field(description="Disk health status", examples=["healthy"])
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
class HealthChecks(BaseModel):
"""Individual health checks."""
database: DatabaseHealth | None = None
disk: DiskHealth | None = None
class HealthResponse(BaseModel):
"""Overall health check response."""
status: str = Field(description="Overall health status", examples=["healthy"])
timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"])
version: str = Field(description="API version", examples=["0.1.0"])
checks: HealthChecks = Field(description="Individual health checks")
uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0])
class DatabaseHealthResponse(BaseModel):
"""Database-specific health check response."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
@router.get(
"/health",
response_model=HealthResponse,
-161
View File
@@ -1,161 +0,0 @@
"""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
+74 -18
View File
@@ -3,24 +3,50 @@ import shutil
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.schemas.project import (
ProjectCreate,
ProjectUpdate,
ProjectResponse,
SetDefaultSSHKeyRequest,
)
router = APIRouter(prefix="/projects", tags=["projects"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
class ProjectCreate(BaseModel):
name: str
description: str | None = None
class ProjectUpdate(BaseModel):
name: str | None = None
description: str | None = None
class ProjectResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
owner_id: uuid.UUID
default_ssh_key_id: uuid.UUID | None
class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: uuid.UUID
@router.post(
"",
@@ -31,7 +57,7 @@ router = APIRouter(prefix="/projects", tags=["projects"])
)
async def create_project(
data: ProjectCreate,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Create a new project.
@@ -44,6 +70,7 @@ async def create_project(
Returns:
The newly created project.
"""
user = await _get_user(session, user_id)
project = Project(
name=data.name,
description=data.description,
@@ -63,7 +90,7 @@ async def create_project(
description="Retrieve all projects owned by the authenticated user.",
)
async def list_projects(
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[Project]:
"""List all projects for the authenticated user.
@@ -75,6 +102,7 @@ async def list_projects(
Returns:
List of projects owned by the user.
"""
user = await _get_user(session, user_id)
result = await session.execute(select(Project).where(Project.owner_id == user.id))
return list(result.scalars().all())
@@ -87,8 +115,7 @@ async def list_projects(
)
async def get_project(
project_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Get a specific project by ID.
@@ -101,9 +128,35 @@ async def get_project(
Returns:
The requested project.
"""
return project
await _get_user(session, user_id)
return await _get_owned_project(project_id, user_id, session)
async def _get_owned_project(
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
@router.patch(
"/{project_id}",
@@ -114,8 +167,7 @@ async def get_project(
async def update_project(
project_id: uuid.UUID,
data: ProjectUpdate,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Update a project.
@@ -129,6 +181,8 @@ async def update_project(
Returns:
The updated project.
"""
await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
if data.name is not None:
project.name = data.name
@@ -148,8 +202,7 @@ async def update_project(
)
async def delete_project(
project_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
"""Delete a project and all its repositories.
@@ -162,6 +215,8 @@ async def delete_project(
Returns:
Empty response with 204 status code.
"""
await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
# Delete repositories from disk and database
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
@@ -185,8 +240,7 @@ async def delete_project(
async def set_default_ssh_key(
project_id: uuid.UUID,
data: SetDefaultSSHKeyRequest,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Set the default SSH key for a project.
@@ -200,6 +254,8 @@ async def set_default_ssh_key(
Returns:
The updated project.
"""
user = await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
ssh_key = await session.get(SSHKey, data.ssh_key_id)
if ssh_key is None or ssh_key.user_id != user.id:
-98
View File
@@ -1,98 +0,0 @@
"""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
+28 -5
View File
@@ -5,18 +5,25 @@ from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
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_id, get_db_session
from src.config import Settings
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.schemas.ssh_key import SSHKeyCreate, SSHKeyResponse
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
def _get_fernet() -> Fernet:
"""Generate a valid Fernet key from the session secret."""
@@ -54,6 +61,19 @@ def generate_ssh_key_pair() -> tuple[str, str]:
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
class SSHKeyCreate(BaseModel):
name: str
class SSHKeyResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
public_key: str
created_at: datetime
@router.post(
"",
response_model=SSHKeyResponse,
@@ -63,7 +83,7 @@ def generate_ssh_key_pair() -> tuple[str, str]:
)
async def create_ssh_key(
data: SSHKeyCreate,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> SSHKey:
"""Create a new SSH key pair.
@@ -76,6 +96,7 @@ async def create_ssh_key(
Returns:
The newly created SSH key with public key exposed.
"""
user = await _get_user(session, user_id)
private_key, public_key = generate_ssh_key_pair()
fernet = _get_fernet()
@@ -100,7 +121,7 @@ async def create_ssh_key(
description="List all SSH keys for the authenticated user.",
)
async def list_ssh_keys(
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[SSHKey]:
"""List all SSH keys for the authenticated user.
@@ -112,6 +133,7 @@ async def list_ssh_keys(
Returns:
List of SSH keys owned by the user.
"""
user = await _get_user(session, user_id)
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
return list(result.scalars().all())
@@ -124,7 +146,7 @@ async def list_ssh_keys(
)
async def delete_ssh_key(
key_id: uuid.UUID,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete an SSH key.
@@ -137,6 +159,7 @@ async def delete_ssh_key(
Returns:
None with 204 status code.
"""
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
+18 -52
View File
@@ -4,7 +4,7 @@ import asyncio
import logging
import uuid
from fastapi import APIRouter, Depends, WebSocket
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_db_session
@@ -26,11 +26,6 @@ async def terminal_websocket(
"""WebSocket endpoint for terminal access to a tool instance.
Provides an interactive terminal session inside a running tool instance container.
Supports:
- Auto-reconnection (client reconnects, server spawns new session)
- Heartbeat ping/pong
- Binary and text input frames
- Graceful session end notifications
Args:
websocket: The WebSocket connection.
@@ -39,27 +34,26 @@ async def terminal_websocket(
Returns:
None. Communicates via WebSocket messages.
"""
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
await websocket.accept()
try:
# Parse instance_id
instance_uuid = uuid.UUID(instance_id)
except ValueError:
logger.error("Invalid instance ID: %s", instance_id)
await websocket.close(code=4001, reason="Invalid instance ID")
return
# Authenticate user from session cookie
user_id = await _get_user_from_websocket(websocket, db_session)
if user_id is None:
logger.warning(
"Unauthorized terminal access attempt for instance %s",
instance_id,
)
logger.warning("Unauthorized terminal access attempt for instance %s", instance_id)
await websocket.close(code=4003, reason="Unauthorized")
return
# Get instance and verify ownership
instance = await db_session.get(ToolInstance, instance_uuid)
if instance is None:
logger.warning("Instance %s not found", instance_id)
@@ -67,65 +61,38 @@ async def terminal_websocket(
return
if instance.owner_id != user_id:
logger.warning(
"Forbidden terminal access for instance %s by user %s",
instance_id,
user_id,
)
logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id)
await websocket.close(code=4003, reason="Forbidden")
return
if instance.status != "running" or not instance.container_id:
logger.warning(
"Instance %s not running (status=%s, container_id=%s)",
instance_id,
instance.status,
instance.container_id,
)
logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id)
await websocket.close(code=4004, reason="Instance not running")
return
logger.info(
"Creating terminal session for instance %s (container_id=%s)",
instance_id,
instance.container_id,
)
logger.info("Creating terminal session for instance %s (container_id=%s)", instance_id, instance.container_id)
# Create terminal session
try:
session = await terminal_manager.create_session(
instance_uuid,
instance.container_id,
websocket,
)
logger.info(
"Terminal session created successfully for instance %s",
instance_id,
)
logger.info("Terminal session created successfully for instance %s", instance_id)
# Send connected status
await websocket.send_json({"type": "status", "status": "connected"})
# Monitor session health and echo state
while session.is_alive() and not session.closed:
# Check echo state periodically
new_echo_state = await session.check_echo_state()
if new_echo_state is not None:
await websocket.send_json(
{"type": "set_echo_state", "enabled": new_echo_state},
)
await asyncio.sleep(1.0)
# Keep connection alive until session ends
# The terminal_manager handles I/O loops, we just wait here
while session.is_alive() and not session._closed:
await asyncio.sleep(0.5)
# Session ended — determine reason and notify client
exit_reason = session.get_exit_reason() or "process_exit"
await websocket.send_json({"type": "session_ended", "reason": exit_reason})
await websocket.close(code=1000, reason=f"Session ended: {exit_reason}")
except Exception:
logger.exception(
"Terminal session error for instance %s",
instance_id,
)
await websocket.close(code=4000, reason="Terminal session error")
except Exception as exc:
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
await websocket.close(code=4000, reason=f"Error: {exc}")
finally:
# Cleanup will be handled by the session manager
pass
@@ -141,7 +108,6 @@ async def _get_user_from_websocket(
Returns:
The user's UUID if authenticated, None otherwise.
"""
from src.auth.session import decode_session_cookie
from src.config import Settings
+110 -1
View File
@@ -4,19 +4,128 @@ import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.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"])
class ToolConfigCreate(BaseModel):
tool_type_id: str = Field(description="UUID of the tool type")
project_id: str | None = Field(default=None, description="Optional project ID for project-scoped config")
key: str = Field(description="Config key name")
value: str = Field(description="Config value")
config_type: str = Field(default="env", description="Type: env or file")
file_path: str | None = Field(default=None, description="File path for file-type configs")
port_override: int | None = Field(default=None, description="Port override (1-65535)")
start_command: str | None = Field(default=None, description="Override container start command")
working_directory: str | None = Field(default=None, description="Working directory inside container")
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
@field_validator("port_override")
@classmethod
def validate_port(cls, v: int | None) -> int | None:
if v is None:
return v
if v < 1 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
return v
@field_validator("environment_variables")
@classmethod
def validate_env_vars(cls, v: dict | None) -> dict | None:
if v is None:
return v
if not isinstance(v, dict):
raise ValueError("environment_variables must be a JSON object")
return v
@field_validator("volumes")
@classmethod
def validate_volumes(cls, v: list | None) -> list | None:
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
class ToolConfigUpdate(BaseModel):
key: str | None = Field(default=None, description="Config key name")
value: str | None = Field(default=None, description="Config value")
config_type: str | None = Field(default=None, description="Type: env or file")
file_path: str | None = Field(default=None, description="File path for file-type configs")
port_override: int | None = Field(default=None, description="Port override (1-65535)")
start_command: str | None = Field(default=None, description="Override container start command")
working_directory: str | None = Field(default=None, description="Working directory inside container")
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
@field_validator("port_override")
@classmethod
def validate_port(cls, v: int | None) -> int | None:
if v is None:
return v
if v < 1 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
return v
@field_validator("environment_variables")
@classmethod
def validate_env_vars(cls, v: dict | None) -> dict | None:
if v is None:
return v
if not isinstance(v, dict):
raise ValueError("environment_variables must be a JSON object")
return v
@field_validator("volumes")
@classmethod
def validate_volumes(cls, v: list | None) -> list | None:
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
class ToolConfigResponse(BaseModel):
id: str
tool_type_id: str
project_id: str | None
key: str
value: str
config_type: str
file_path: str | None
port_override: int | None
start_command: str | None
working_directory: str | None
environment_variables: dict | None
volumes: list[dict] | None
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
async def list_configs(
tool_type_id: str | None = None,
-424
View File
@@ -1,424 +0,0 @@
"""Tool definition API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool_type import ToolType
from src.services.manifest_compiler import (
compile_compose,
compile_dockerfile,
compile_entrypoint,
compute_image_tag,
deep_merge,
resolve_base,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tool-definitions", tags=["tool-definitions"])
class CreateToolDefinitionRequest(BaseModel):
"""Request body for creating a tool definition manifest."""
model_config = {"extra": "ignore"}
name: str = Field(description="Unique identifier (kebab-case)")
display_name: str = Field(description="Human-readable name")
description: str | None = Field(default=None)
category: str = Field(default="development")
interface_type: str = Field(default="terminal", description="web or terminal")
base_image: str | None = Field(default=None, description="Direct base image")
base_definition_id: str | None = Field(
default=None, description="Reference to a base definition"
)
base_version: str = Field(default="latest")
manifest: dict = Field(description="The full manifest JSON")
class UpdateToolDefinitionRequest(BaseModel):
"""Request body for updating a tool definition manifest."""
model_config = {"extra": "ignore"}
display_name: str | None = Field(default=None)
description: str | None = Field(default=None)
category: str | None = Field(default=None)
manifest: dict | None = Field(default=None)
base_version: str | None = Field(default=None)
@router.post(
"",
summary="Create tool definition",
description="Create a new tool definition manifest.",
)
async def create_tool_definition(
data: CreateToolDefinitionRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a new tool definition manifest.
Args:
data: Manifest data.
user_id: Authenticated user ID.
session: Database session.
Returns:
Dictionary with created definition details.
"""
# Validate base reference
if not data.base_image and not data.base_definition_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Either base_image or base_definition_id is required",
)
base_def_id = None
if data.base_definition_id:
try:
base_def_id = uuid.UUID(data.base_definition_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid base_definition_id: {data.base_definition_id}",
)
base_def = await session.get(ToolDefinitionManifest, base_def_id)
if not base_def:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Base definition not found: {data.base_definition_id}",
)
if not base_def.is_base:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Referenced definition is not a base definition",
)
# Check name uniqueness
existing = await session.execute(
select(ToolDefinitionManifest).where(ToolDefinitionManifest.name == data.name)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Tool definition '{data.name}' already exists",
)
definition = ToolDefinitionManifest(
name=data.name,
display_name=data.display_name,
description=data.description,
category=data.category,
interface_type=data.interface_type,
base_image=data.base_image,
base_definition_id=base_def_id,
base_version=data.base_version,
manifest=data.manifest,
created_by_id=user_id,
)
session.add(definition)
await session.commit()
await session.refresh(definition)
logger.info("Created tool definition %s (%s)", definition.id, definition.name)
return {
"id": str(definition.id),
"name": definition.name,
"display_name": definition.display_name,
"description": definition.description,
"category": definition.category,
"interface_type": definition.interface_type,
"base_image": definition.base_image,
"base_definition_id": str(definition.base_definition_id)
if definition.base_definition_id
else None,
"base_version": definition.base_version,
"manifest": definition.manifest,
"is_base": definition.is_base,
"created_at": definition.created_at.isoformat(),
}
@router.get(
"",
summary="List tool definitions",
description="List all tool definition manifests.",
)
async def list_tool_definitions(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
include_bases: bool = True,
) -> dict:
"""List all tool definition manifests.
Args:
user_id: Authenticated user ID.
session: Database session.
include_bases: Whether to include base definitions.
Returns:
Dictionary containing list of definitions.
"""
query = select(ToolDefinitionManifest)
if not include_bases:
query = query.where(ToolDefinitionManifest.is_base == False)
result = await session.execute(
query.order_by(ToolDefinitionManifest.created_at.desc())
)
definitions = result.scalars().all()
return {
"definitions": [
{
"id": str(d.id),
"name": d.name,
"display_name": d.display_name,
"description": d.description,
"category": d.category,
"interface_type": d.interface_type,
"is_base": d.is_base,
"base_image": d.base_image,
"base_definition_id": str(d.base_definition_id)
if d.base_definition_id
else None,
"base_version": d.base_version,
"version": d.version,
"created_at": d.created_at.isoformat(),
}
for d in definitions
]
}
@router.get(
"/{definition_id}",
summary="Get tool definition",
description="Get a specific tool definition manifest.",
)
async def get_tool_definition(
definition_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get a specific tool definition manifest.
Args:
definition_id: UUID of the definition.
user_id: Authenticated user ID.
session: Database session.
Returns:
Dictionary with definition details.
"""
definition = await session.get(ToolDefinitionManifest, definition_id)
if not definition:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool definition not found: {definition_id}",
)
return {
"id": str(definition.id),
"name": definition.name,
"display_name": definition.display_name,
"description": definition.description,
"category": definition.category,
"interface_type": definition.interface_type,
"base_image": definition.base_image,
"base_definition_id": str(definition.base_definition_id)
if definition.base_definition_id
else None,
"base_version": definition.base_version,
"manifest": definition.manifest,
"dockerfile_cache": definition.dockerfile_cache,
"compose_cache": definition.compose_cache,
"version": definition.version,
"is_base": definition.is_base,
"created_at": definition.created_at.isoformat(),
"updated_at": definition.updated_at.isoformat(),
}
@router.put(
"/{definition_id}",
summary="Update tool definition",
description="Update a tool definition manifest.",
)
async def update_tool_definition(
definition_id: uuid.UUID,
data: UpdateToolDefinitionRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a tool definition manifest.
Args:
definition_id: UUID of the definition.
data: Update data.
user_id: Authenticated user ID.
session: Database session.
Returns:
Dictionary with updated definition details.
"""
definition = await session.get(ToolDefinitionManifest, definition_id)
if not definition:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool definition not found: {definition_id}",
)
if data.display_name is not None:
definition.display_name = data.display_name
if data.description is not None:
definition.description = data.description
if data.category is not None:
definition.category = data.category
if data.manifest is not None:
definition.manifest = data.manifest
if data.base_version is not None:
definition.base_version = data.base_version
await session.commit()
await session.refresh(definition)
logger.info("Updated tool definition %s (%s)", definition.id, definition.name)
return {
"id": str(definition.id),
"name": definition.name,
"display_name": definition.display_name,
"manifest": definition.manifest,
"updated_at": definition.updated_at.isoformat(),
}
@router.delete(
"/{definition_id}",
summary="Delete tool definition",
description="Delete a tool definition manifest.",
)
async def delete_tool_definition(
definition_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Delete a tool definition manifest.
Args:
definition_id: UUID of the definition.
user_id: Authenticated user ID.
session: Database session.
Returns:
Dictionary with deletion status.
"""
definition = await session.get(ToolDefinitionManifest, definition_id)
if not definition:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool definition not found: {definition_id}",
)
# Check if any tool types reference this manifest
result = await session.execute(
select(ToolType).where(ToolType.manifest_id == definition_id)
)
referencing = result.scalars().all()
if referencing:
tool_names = ", ".join(t.name for t in referencing)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Cannot delete: referenced by tool types: {tool_names}",
)
await session.delete(definition)
await session.commit()
logger.info("Deleted tool definition %s (%s)", definition.id, definition.name)
return {"status": "deleted", "id": str(definition_id)}
@router.post(
"/{definition_id}/compile",
summary="Compile tool definition",
description="Compile a manifest to Dockerfile + Compose preview without building.",
)
async def compile_tool_definition(
definition_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Compile a manifest to Dockerfile + Compose preview.
Args:
definition_id: UUID of the definition.
user_id: Authenticated user ID.
session: Database session.
Returns:
Dictionary with compiled Dockerfile, Compose, and image tag.
"""
definition = await session.get(ToolDefinitionManifest, definition_id)
if not definition:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool definition not found: {definition_id}",
)
manifest = dict(definition.manifest)
# Resolve base if referenced
if definition.base_definition_id:
base_def = await session.get(
ToolDefinitionManifest, definition.base_definition_id
)
if base_def:
base_manifest = dict(base_def.manifest)
manifest = resolve_base(deep_merge(base_manifest, manifest))
# Compile
dockerfile = compile_dockerfile(manifest)
entrypoint = compile_entrypoint(manifest)
image_tag = compute_image_tag(definition.name, manifest)
# Dummy compose with placeholder variables
dummy_vars = {
"IMAGE_TAG": image_tag,
"INSTANCE_NAME": f"{definition.name}-preview",
"INSTANCE_DIR": "/data/instances/preview",
"REPO_PATH": "/data/repos/preview",
"SSH_PATH": "/data/instances/preview/.ssh",
"TOOL_PORT": "8080",
"EXTRA_ENV": {},
"EXTRA_VOLUMES": [],
}
compose = compile_compose(manifest, dummy_vars)
# Update cache
definition.dockerfile_cache = dockerfile
definition.compose_cache = compose
await session.commit()
return {
"id": str(definition.id),
"name": definition.name,
"dockerfile": dockerfile,
"entrypoint": entrypoint,
"compose": compose,
"image_tag": image_tag,
}
File diff suppressed because it is too large Load Diff
+254 -9
View File
@@ -3,17 +3,25 @@ from datetime import datetime
import yaml
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_type import ToolType
from src.models.user import User
from src.schemas.tool_type import ToolTypeCreate, ToolTypeResponse, ToolTypeUpdate, ToolTypeValidateRequest
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _require_admin(user: User) -> None:
"""Check if user has admin privileges.
@@ -25,6 +33,230 @@ async def _require_admin(user: User) -> None:
pass
class ToolTypeCreate(BaseModel):
name: str
display_name: str
description: str | None = None
default_port: int
definition_type: str = "compose"
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
required_variables: list[str] = []
category: str = "other"
interfaces: list[str] = ["web"]
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str) -> str:
if v not in ("compose", "dockerfile"):
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "compose":
return v
if v is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
try:
parsed = yaml.safe_load(v)
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 v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "dockerfile":
return v
if v is None:
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
@field_validator("default_port")
@classmethod
def validate_default_port(cls, v: int, info) -> int:
if v <= 0 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
# Get compose_template from the model data
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
try:
parsed = yaml.safe_load(template)
except yaml.YAMLError:
return v
# Check if the port is exposed in any service
port_str = str(v)
port_exposed = False
if isinstance(parsed, dict) and "services" in parsed:
for service_name, service_config in parsed["services"].items():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str):
# Format: "8443:8443" or "8443"
if port_str in port_mapping:
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == v:
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
return v
@field_validator("required_variables")
@classmethod
def validate_required_variables(cls, v: list[str], info) -> list[str]:
if not v:
return v
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
for var in v:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise ValueError(f"Required variable '{var}' not found in compose template")
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if self.definition_type == "compose" and self.compose_template is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
return self
class ToolTypeUpdate(BaseModel):
display_name: str | None = None
description: str | None = None
default_port: int | None = None
definition_type: str | None = None
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
required_variables: list[str] | None = None
category: str | None = None
interfaces: list[str] | None = None
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("compose", "dockerfile"):
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "compose":
return v
try:
parsed = yaml.safe_load(v)
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 v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "dockerfile":
return v
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
class ToolTypeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
display_name: str
description: str | None
category: str
interfaces: list[str]
default_port: int
definition_type: str
compose_template: str | None
dockerfile_template: str | None
build_context: dict | None
readiness_probe: dict | None
required_variables: list[str]
is_builtin: bool
created_by_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
@router.post(
"",
response_model=ToolTypeResponse,
@@ -34,7 +266,7 @@ async def _require_admin(user: User) -> None:
)
async def create_tool_type(
data: ToolTypeCreate,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Create a new tool type.
@@ -47,6 +279,7 @@ async def create_tool_type(
Returns:
The newly created tool type.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
# Check for duplicate name
@@ -83,7 +316,7 @@ async def create_tool_type(
description="List all available tool types including built-in and custom ones.",
)
async def list_tool_types(
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[ToolType]:
"""List all tool types.
@@ -95,6 +328,7 @@ async def list_tool_types(
Returns:
List of all tool types ordered by name.
"""
await _get_user(session, user_id)
result = await session.execute(select(ToolType).order_by(ToolType.name))
return list(result.scalars().all())
@@ -107,7 +341,7 @@ async def list_tool_types(
)
async def get_tool_type(
tool_type_id: uuid.UUID,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Get a specific tool type by ID.
@@ -120,6 +354,7 @@ async def get_tool_type(
Returns:
The requested tool type.
"""
await _get_user(session, user_id)
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
@@ -135,7 +370,7 @@ async def get_tool_type(
async def update_tool_type(
tool_type_id: uuid.UUID,
data: ToolTypeUpdate,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Update a tool type.
@@ -149,6 +384,7 @@ async def update_tool_type(
Returns:
The updated tool type.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
tool_type = await session.get(ToolType, tool_type_id)
@@ -231,6 +467,12 @@ async def update_tool_type(
return tool_type
class ToolTypeValidateRequest(BaseModel):
definition_type: str
compose_template: str | None = None
dockerfile_template: str | None = None
@router.post(
"/validate",
summary="Validate tool type template",
@@ -238,7 +480,7 @@ async def update_tool_type(
)
async def validate_tool_type_template(
data: ToolTypeValidateRequest,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Validate a tool type template syntax.
@@ -251,6 +493,7 @@ async def validate_tool_type_template(
Returns:
Validation result with success status and any errors.
"""
await _get_user(session, user_id)
errors = []
@@ -291,7 +534,7 @@ async def validate_tool_type_template(
)
async def validate_tool_type(
tool_type_id: uuid.UUID,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Validate a tool type's template syntax.
@@ -304,6 +547,7 @@ async def validate_tool_type(
Returns:
Validation result with success status and any errors.
"""
await _get_user(session, user_id)
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
@@ -345,7 +589,7 @@ async def validate_tool_type(
)
async def delete_tool_type(
tool_type_id: uuid.UUID,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a tool type.
@@ -358,6 +602,7 @@ async def delete_tool_type(
Returns:
None with 204 status code.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
tool_type = await session.get(ToolType, tool_type_id)
-87
View File
@@ -1,87 +0,0 @@
"""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",
)
+38 -9
View File
@@ -2,17 +2,26 @@ import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
logger = logging.getLogger(__name__)
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
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_id, get_db_session
from src.models.user import User
from src.models.user_config import UserConfig
from src.schemas.user_config import UserConfigResponse, UserConfigUpdate
router = APIRouter(prefix="/users/me", tags=["user-config"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
"""Get or create user config record.
@@ -24,16 +33,34 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
Returns:
The user's config, creating a new one if it doesn't exist.
"""
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user.id))
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
config = result.scalar_one_or_none()
if config is None:
config = UserConfig(user_id=user.id, config={})
config = UserConfig(user_id=user_id, config={})
session.add(config)
await session.commit()
await session.refresh(config)
return config
class UserConfigResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
default_editor: str | None = None
theme: str = "system"
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
class UserConfigUpdate(BaseModel):
default_editor: str | None = None
theme: str | None = None
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
@router.get(
"/config",
response_model=UserConfigResponse,
@@ -41,7 +68,7 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
description="Get the current user's configuration settings.",
)
async def get_user_config(
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> UserConfigResponse:
"""Get the current user's configuration.
@@ -53,7 +80,8 @@ async def get_user_config(
Returns:
The user's configuration settings.
"""
config = await _get_or_create_config(session, user.id)
_user = await _get_user(session, user_id)
config = await _get_or_create_config(session, user_id)
return UserConfigResponse.model_validate(config.config)
@@ -65,7 +93,7 @@ async def get_user_config(
)
async def update_user_config(
data: UserConfigUpdate,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> UserConfigResponse:
"""Update the current user's configuration.
@@ -78,11 +106,12 @@ async def update_user_config(
Returns:
The updated user configuration.
"""
config = await _get_or_create_config(session, user.id)
_user = await _get_user(session, user_id)
config = await _get_or_create_config(session, user_id)
# Merge updates
update_data = data.model_dump(exclude_unset=True)
logger.info("Updating user config for user %s: %s", user.id, update_data)
logger.info("Updating user config for user %s: %s", user_id, update_data)
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
config.config = {**config.config, **update_data}
+29 -47
View File
@@ -2,14 +2,11 @@ import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session
from src.models.tool_instance import ToolInstance
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.user import User
from src.schemas.tool_instance import SessionItemResponse, SessionListResponse
from src.schemas.user import UserProfileResponse, UserProfileUpdate
router = APIRouter(prefix="/users", tags=["users"])
@@ -19,6 +16,27 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
avatar_url: str | None
class UserProfileUpdate(BaseModel):
name: str | None = None
email: str | None = None
@router.get(
"/me",
@@ -27,7 +45,7 @@ MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
description="Retrieve the profile of the currently authenticated user.",
)
async def get_profile(
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Get the current user's profile.
@@ -39,7 +57,7 @@ async def get_profile(
Returns:
The user's profile information.
"""
return user
return await _get_user(session, user_id)
@router.put(
@@ -50,7 +68,7 @@ async def get_profile(
)
async def update_profile(
data: UserProfileUpdate,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Update the current user's profile.
@@ -63,6 +81,7 @@ async def update_profile(
Returns:
The updated user profile.
"""
user = await _get_user(session, user_id)
if data.name is not None:
if len(data.name.strip()) == 0:
@@ -87,7 +106,7 @@ async def update_profile(
)
async def upload_avatar(
file: UploadFile,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Upload a profile avatar image.
@@ -100,6 +119,7 @@ async def upload_avatar(
Returns:
The updated user profile with new avatar URL.
"""
user = await _get_user(session, user_id)
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
@@ -134,41 +154,3 @@ async def upload_avatar(
await session.commit()
await session.refresh(user)
return user
@router.get(
"/me/sessions",
response_model=SessionListResponse,
summary="Get current user sessions",
description="Retrieve all tool instances (sessions) for the authenticated user.",
)
async def get_user_sessions(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
) -> SessionListResponse:
"""Return all tool instances for the current user with related names."""
result = await session.execute(
select(ToolInstance)
.where(ToolInstance.owner_id == user.id)
.order_by(ToolInstance.created_at.desc())
)
instances = result.scalars().all()
sessions = [
SessionItemResponse(
id=str(inst.id),
display_name=inst.display_name,
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_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [],
repository_name=inst.repository.name if inst.repository else "Unknown",
repository_id=str(inst.repository_id),
project_name=inst.project.name if inst.project else "Unknown",
project_id=str(inst.project_id),
status=inst.status,
url=inst.url,
)
for inst in instances
]
return SessionListResponse(sessions=sessions)
-114
View File
@@ -1,114 +0,0 @@
"""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}
-203
View File
@@ -1,203 +0,0 @@
"""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
],
}
-60
View File
@@ -1,60 +0,0 @@
"""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
]
-450
View File
@@ -1,450 +0,0 @@
"""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
-27
View File
@@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.session import decode_session_cookie
from src.config import Settings
from src.database import SessionLocal
from src.models.project import Project
from src.models.user import User
@@ -48,29 +47,3 @@ async def get_current_user(
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def get_owned_project(
project_id: uuid.UUID,
user: User = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project (injected from path parameter).
user: The currently authenticated user.
db_session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: 404 if project not found, 403 if user is not the owner.
"""
project = await db_session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
+3 -3
View File
@@ -2,7 +2,7 @@ import hmac
import hashlib
import json
import base64
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import Any
from src.config import Settings
@@ -23,7 +23,7 @@ def create_session_cookie(*, settings: Settings, user_id: str) -> str:
"""Create a signed session cookie value."""
payload = {
"user_id": user_id,
"exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
"exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
}
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
@@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str,
payload = json.loads(payload_bytes)
# Check expiry
if payload.get("exp", 0) < int(datetime.now(timezone.utc).timestamp()):
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()):
raise ValueError("session expired")
return payload
+152 -3
View File
@@ -7,6 +7,8 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select, text
from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router
from src.api.git_repositories import router as git_repositories_router
@@ -16,7 +18,6 @@ from src.api.ssh_keys import router as ssh_keys_router
from src.api.terminal import router as terminal_router
from src.api.instance_proxy import router as instance_proxy_router
from src.api.config_folders import router as config_folders_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 sessions_router
@@ -30,7 +31,7 @@ from src.logging_config import (
RequestLoggingMiddleware,
configure_logging,
)
from src.seeds.builtin_tool_types import seed_builtin_tool_types
from src.models.tool_type import ToolType
# Configure logging early
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -102,6 +103,155 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
)
async def _table_exists(session, table_name: str) -> bool:
"""Check if a table exists in the database."""
try:
result = await session.execute(
text("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = :table_name
)
"""),
{"table_name": table_name},
)
return result.scalar() or False
except Exception:
return False
async def seed_builtin_tool_types():
async with SessionLocal() as session:
# Check if tool_types table exists before attempting to seed
if not await _table_exists(session, "tool_types"):
logger.warning(
"tool_types table does not exist. Skipping seeding. "
"Migrations may not have run yet."
)
return
builtin_types = [
{
"name": "code-server",
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interfaces": ["web"],
"compose_template": """version: "3.8"
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: {{TOOL_NAME}}
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- {{REPO_PATH}}:/config/workspace
ports:
- "8443:8443"
restart: unless-stopped""",
"default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "jupyter-notebook",
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interfaces": ["web"],
"default_port": 8888,
"compose_template": """version: "3.8"
services:
jupyter:
image: jupyter/scipy-notebook:latest
container_name: {{TOOL_NAME}}
environment:
- JUPYTER_ENABLE_LAB=yes
volumes:
- {{REPO_PATH}}:/home/jovyan/work
ports:
- "8888:8888"
restart: unless-stopped""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "opencode",
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interfaces": ["terminal"],
"default_port": 3000,
"compose_template": """version: "3.8"
services:
opencode:
image: node:20-slim
container_name: {{TOOL_NAME}}
working_dir: /workspace
environment:
- HOME=/tmp
volumes:
- {{REPO_PATH}}:/workspace
- opencode_home:/tmp
ports:
- "3000:3000"
command: >
sh -c "set -x &&
apt-get update && apt-get install -y git ca-certificates &&
echo 'Installing opencode...' &&
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
which opencode || echo 'ERROR: opencode not in PATH' &&
npm bin -g &&
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
echo 'export PATH=\"$(npm bin -g):\$PATH\"' >> /root/.bashrc &&
echo 'cd /workspace' >> /root/.bashrc &&
echo 'OpenCode installation complete' &&
cd /workspace &&
exec tail -f /dev/null"
stdin_open: true
tty: true
restart: unless-stopped
volumes:
opencode_home:""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
for tool_data in builtin_types:
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
if not existing:
tool_type = ToolType(
name=tool_data["name"],
display_name=tool_data["display_name"],
description=tool_data["description"],
category=tool_data["category"],
interfaces=tool_data["interfaces"],
definition_type="compose",
compose_template=tool_data["compose_template"],
required_variables=tool_data["required_variables"],
default_port=tool_data.get("default_port"),
is_builtin=True,
)
session.add(tool_type)
logger.info("Created built-in tool type: %s", tool_data["name"])
else:
# Update existing built-in tool types to reflect code changes
existing.display_name = tool_data["display_name"]
existing.description = tool_data["description"]
existing.category = tool_data["category"]
existing.interfaces = tool_data["interfaces"]
existing.definition_type = "compose"
existing.compose_template = tool_data["compose_template"]
existing.required_variables = tool_data["required_variables"]
existing.default_port = tool_data.get("default_port")
logger.info("Updated built-in tool type: %s", tool_data["name"])
await session.commit()
logger.info("Built-in tool types seeded successfully.")
@app.on_event("startup")
async def on_startup():
logger.info("Starting up Headquarter API...")
@@ -127,7 +277,6 @@ app.include_router(git_repositories_router)
app.include_router(user_config_router)
app.include_router(tool_types_router)
app.include_router(config_folders_router)
app.include_router(config_profiles_router)
app.include_router(tool_instances_router)
app.include_router(tool_configs_router)
app.include_router(sessions_router)
+9 -5
View File
@@ -1,7 +1,7 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, JSON, String
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -17,10 +17,10 @@ class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
target_path: Mapped[str] = mapped_column(String(1024), nullable=False)
mode: Mapped[str] = mapped_column(String(10), nullable=False, default="rw")
files: Mapped[dict[str, str] | None] = mapped_column(
JSON, default=dict, nullable=True
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
content: Mapped[str | None] = mapped_column(Text, nullable=True)
source_profile_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
@@ -29,3 +29,7 @@ class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
foreign_keys=[profile_id],
back_populates="mounts",
)
source_profile: Mapped["ConfigProfile | None"] = relationship(
"ConfigProfile",
foreign_keys=[source_profile_id],
)
+2 -22
View File
@@ -1,17 +1,13 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.project import Project
from src.models.tool_type import ToolType
from src.models.user import User
@@ -24,35 +20,19 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
project_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
)
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
environment_variables: Mapped[dict[str, str] | None] = mapped_column(
JSON, default=dict, nullable=True
)
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
user: Mapped["User"] = relationship()
project: Mapped["Project | None"] = relationship()
tool_type: Mapped["ToolType | None"] = relationship()
includes: Mapped[list["ConfigInclude"]] = relationship(
"ConfigInclude",
primaryjoin="ConfigProfile.id == ConfigInclude.profile_id",
foreign_keys="ConfigInclude.profile_id",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigInclude.order_index",
)
mounts: Mapped[list["ConfigMount"]] = relationship(
"ConfigMount",
primaryjoin="ConfigProfile.id == ConfigMount.profile_id",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigMount.order_index",
-30
View File
@@ -1,30 +0,0 @@
"""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,
)
-39
View File
@@ -1,39 +0,0 @@
"""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,
)
-43
View File
@@ -1,43 +0,0 @@
"""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
)
-37
View File
@@ -1,37 +0,0 @@
"""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,67 +0,0 @@
"""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],
)
-50
View File
@@ -1,50 +0,0 @@
"""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
View File
@@ -1 +0,0 @@
"""Pydantic request/response schemas."""
-44
View File
@@ -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
-131
View File
@@ -1,131 +0,0 @@
"""Config profile request/response schemas."""
from typing import Any
from pydantic import BaseModel, Field, field_validator
MAX_MOUNT_PATH_LENGTH = 1024
class ConfigProfileCreate(BaseModel):
name: str = Field(description="Profile name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Profile name cannot be empty")
if len(v) > 255:
raise ValueError("Profile name must be 255 characters or less")
return v
class ConfigProfileUpdate(BaseModel):
name: str | None = Field(default=None, description="Profile name")
description: str | None = Field(default=None, description="Optional description")
@field_validator("name")
@classmethod
def validate_name(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if not v:
raise ValueError("Profile name cannot be empty")
if len(v) > 255:
raise ValueError("Profile name must be 255 characters or less")
return v
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
created_at: str
updated_at: str
class ConfigProfileDetailResponse(ConfigProfileResponse):
includes: list[dict[str, Any]]
mounts: list[dict[str, Any]]
class ConfigIncludeCreate(BaseModel):
included_profile_id: str = Field(description="UUID of the profile to include")
order_index: int = Field(default=0, description="Order index for include resolution")
class ConfigIncludeUpdate(BaseModel):
order_index: int = Field(description="Order index for include resolution")
class ConfigIncludeResponse(BaseModel):
id: str
profile_id: str
included_profile_id: str
included_profile_name: str | None
order_index: int
created_at: str
updated_at: str
class ConfigMountCreate(BaseModel):
target_path: str = Field(description="Absolute target path in container")
mode: str = Field(default="rw", description="Mount mode (rw or ro)")
files: dict[str, str] | None = Field(
default=None, description="Files as {path: content}"
)
order_index: int = Field(default=0, description="Order index for mount resolution")
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Target path must be absolute (start with /)")
if ".." in v:
raise ValueError("Target path cannot contain parent directory references (..)")
if len(v) > MAX_MOUNT_PATH_LENGTH:
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
return v
class ConfigMountUpdate(BaseModel):
target_path: str | None = Field(default=None, description="Absolute target path in container")
mode: str | None = Field(default=None, description="Mount mode (rw or ro)")
files: dict[str, str] | None = Field(
default=None, description="Files as {path: content}"
)
order_index: int | None = Field(default=None, description="Order index for mount resolution")
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Target path must be absolute (start with /)")
if ".." in v:
raise ValueError("Target path cannot contain parent directory references (..)")
if len(v) > MAX_MOUNT_PATH_LENGTH:
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
return v
class ConfigMountResponse(BaseModel):
id: str
profile_id: str
target_path: str
mode: str
files: dict[str, str] | None
order_index: int
created_at: str
updated_at: str
class DefaultProfilesUpdate(BaseModel):
default_profiles: dict[str, str] = Field(
description="Mapping of tool_type_id to profile_id"
)
-129
View File
@@ -1,129 +0,0 @@
"""Git repository request/response schemas."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class GitRepositoryCreate(BaseModel):
name: str
remote_url: str | None = None
force_original_url: bool = False
class URLParseRequest(BaseModel):
url: str
class URLParseResponse(BaseModel):
original_url: str
base_url: str | None
is_valid_clone_url: bool
needs_parsing: bool
host: str | None
message: str
error_code: str | None
class GitRepositoryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
path: str
project_id: uuid.UUID
owner_id: uuid.UUID
is_mirror: bool
remote_url: str | None
last_push: datetime | None
created_at: datetime
updated_at: datetime
class FileListResponse(BaseModel):
path: str
branch: str
entries: list[dict]
class FileContentResponse(BaseModel):
path: str
branch: str
content: str
size: int
encoding: str
language: str | None
is_binary: bool
last_commit: dict | None
class BranchesResponse(BaseModel):
branches: list[dict]
default_branch: str
class FileUpdateRequest(BaseModel):
path: str
branch: str
content: str
commit_message: str
class FileUpdateResponse(BaseModel):
commit_hash: str
message: str
branch: str
class StatusResponse(BaseModel):
branch: str
modified: list[str]
added: list[str]
deleted: list[str]
untracked: list[str]
renamed: list[str]
ahead: int
behind: int
class BranchCreateRequest(BaseModel):
name: str
base_branch: str = "HEAD"
class CheckoutRequest(BaseModel):
branch: str
class CommitRequest(BaseModel):
message: str
files: list[str] | None = None
class CommitResponse(BaseModel):
commit_hash: str
message: str
class FetchResponse(BaseModel):
message: str
class PullResponse(BaseModel):
message: str
class PushResponse(BaseModel):
message: str
class MergeRequest(BaseModel):
source_branch: str
target_branch: str | None = None
message: str | None = None
class MergeResponse(BaseModel):
commit_hash: str
message: str
-50
View File
@@ -1,50 +0,0 @@
"""Health check response schemas."""
from pydantic import BaseModel, Field
class DatabaseHealth(BaseModel):
"""Database health check result."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(
description="Query response time in milliseconds", examples=[5.2]
)
class DiskHealth(BaseModel):
"""Disk space health check result."""
status: str = Field(description="Disk health status", examples=["healthy"])
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
class HealthChecks(BaseModel):
"""Individual health checks."""
database: DatabaseHealth | None = None
disk: DiskHealth | None = None
class HealthResponse(BaseModel):
"""Overall health check response."""
status: str = Field(description="Overall health status", examples=["healthy"])
timestamp: str = Field(
description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"]
)
version: str = Field(description="API version", examples=["0.1.0"])
checks: HealthChecks = Field(description="Individual health checks")
uptime_seconds: float = Field(
description="Server uptime in seconds", examples=[3600.0]
)
class DatabaseHealthResponse(BaseModel):
"""Database-specific health check response."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(
description="Query response time in milliseconds", examples=[5.2]
)
-25
View File
@@ -1,25 +0,0 @@
"""Project request/response schemas."""
from pydantic import BaseModel
class ProjectCreate(BaseModel):
name: str
description: str | None = None
class ProjectUpdate(BaseModel):
name: str | None = None
description: str | None = None
class ProjectResponse(BaseModel):
id: str
name: str
description: str | None
created_at: str
updated_at: str
class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: str
-16
View File
@@ -1,16 +0,0 @@
"""SSH key request/response schemas."""
from pydantic import BaseModel
class SSHKeyCreate(BaseModel):
name: str
public_key: str
class SSHKeyResponse(BaseModel):
id: str
name: str
public_key: str
fingerprint: str
created_at: str
-47
View File
@@ -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
-41
View File
@@ -1,41 +0,0 @@
"""Tool instance request/response schemas."""
from pydantic import BaseModel, Field
class CreateInstanceRequest(BaseModel):
"""Request body for creating a tool instance."""
model_config = {"extra": "ignore"}
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(
default=None, description="Optional display name for the instance"
)
config_profile_id: str | None = Field(
default=None, description="Optional config profile ID to apply to the instance"
)
class SessionItemResponse(BaseModel):
"""Lightweight session summary for sidebar and dashboard."""
model_config = {"extra": "ignore"}
id: str = Field(description="Session (tool instance) ID")
display_name: str = Field(description="Display name of the session")
tool_type_name: str = Field(description="Name of the tool type")
tool_icon: str | None = Field(default=None, description="Icon URL for the tool type")
tool_type_interfaces: list[str] = Field(default_factory=list, description="Supported interfaces")
repository_name: str = Field(description="Name of the repository")
repository_id: str = Field(description="Repository ID")
project_name: str = Field(description="Name of the project")
project_id: str = Field(description="Project ID")
status: str = Field(description="Current status")
url: str | None = Field(default=None, description="Access URL")
class SessionListResponse(BaseModel):
"""Response wrapping a list of session summaries."""
sessions: list[SessionItemResponse]
-204
View File
@@ -1,204 +0,0 @@
"""Tool type request/response schemas."""
import uuid
from datetime import datetime
import yaml
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
class ToolTypeCreate(BaseModel):
name: str
display_name: str
description: str | None = None
default_port: int
definition_type: str = "compose"
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
required_variables: list[str] = []
category: str = "other"
interfaces: list[str] = ["web"]
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str) -> str:
if v not in ("compose", "dockerfile"):
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "compose":
return v
if v is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
try:
parsed = yaml.safe_load(v)
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 v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "dockerfile":
return v
if v is None:
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
@field_validator("default_port")
@classmethod
def validate_default_port(cls, v: int, info) -> int:
if v <= 0 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
try:
parsed = yaml.safe_load(template)
except yaml.YAMLError:
return v
port_str = str(v)
port_exposed = False
if isinstance(parsed, dict) and "services" in parsed:
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:
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == v:
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
return v
@field_validator("required_variables")
@classmethod
def validate_required_variables(cls, v: list[str], info) -> list[str]:
if not v:
return v
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
for var in v:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise ValueError(f"Required variable '{var}' not found in compose template")
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if self.definition_type == "compose" and self.compose_template is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
return self
class ToolTypeUpdate(BaseModel):
display_name: str | None = None
description: str | None = None
default_port: int | None = None
definition_type: str | None = None
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
required_variables: list[str] | None = None
category: str | None = None
interfaces: list[str] | None = None
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("compose", "dockerfile"):
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "compose":
return v
try:
parsed = yaml.safe_load(v)
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 v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "dockerfile":
return v
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
class ToolTypeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
display_name: str
description: str | None
category: str
interfaces: list[str]
default_port: int
definition_type: str
compose_template: str | None
dockerfile_template: str | None
build_context: dict | None
readiness_probe: dict | None
required_variables: list[str]
is_builtin: bool
created_by_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
class ToolTypeValidateRequest(BaseModel):
definition_type: str
compose_template: str | None = None
dockerfile_template: str | None = None
-19
View File
@@ -1,19 +0,0 @@
"""User request/response schemas."""
import uuid
from pydantic import BaseModel, ConfigDict
class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
avatar_url: str | None
class UserProfileUpdate(BaseModel):
name: str | None = None
email: str | None = None
-21
View File
@@ -1,21 +0,0 @@
"""User config request/response schemas."""
from pydantic import BaseModel, ConfigDict
class UserConfigResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
default_editor: str | None = None
theme: str = "system"
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
class UserConfigUpdate(BaseModel):
default_editor: str | None = None
theme: str | None = None
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
View File
-161
View File
@@ -1,161 +0,0 @@
import logging
from sqlalchemy import select
from src.database import SessionLocal
from src.models.tool_type import ToolType
logger = logging.getLogger(__name__)
async def _table_exists(session, table_name: str) -> bool:
"""Check if a table exists in the database."""
from sqlalchemy import text
try:
result = await session.execute(
text(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = :table_name
)
"""
),
{"table_name": table_name},
)
return result.scalar() or False
except Exception:
return False
async def seed_builtin_tool_types():
async with SessionLocal() as session:
# Check if tool_types table exists before attempting to seed
if not await _table_exists(session, "tool_types"):
logger.warning(
"tool_types table does not exist. Skipping seeding. "
"Migrations may not have run yet."
)
return
builtin_types = [
{
"name": "code-server",
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interfaces": ["web"],
"compose_template": """version: "3.8"
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: {{TOOL_NAME}}
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- {{REPO_PATH}}:/config/workspace
ports:
- "8443:8443"
restart: unless-stopped""",
"default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "jupyter-notebook",
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interfaces": ["web"],
"default_port": 8888,
"compose_template": """version: "3.8"
services:
jupyter:
image: jupyter/scipy-notebook:latest
container_name: {{TOOL_NAME}}
environment:
- JUPYTER_ENABLE_LAB=yes
volumes:
- {{REPO_PATH}}:/home/jovyan/work
ports:
- "8888:8888"
restart: unless-stopped""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "opencode",
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interfaces": ["terminal"],
"default_port": 3000,
"compose_template": """version: "3.8"
services:
opencode:
image: node:20-slim
container_name: {{TOOL_NAME}}
working_dir: /workspace
environment:
- HOME=/tmp
volumes:
- {{REPO_PATH}}:/workspace
- opencode_home:/tmp
ports:
- "3000:3000"
command: >
sh -c "set -x &&
apt-get update && apt-get install -y git ca-certificates &&
echo 'Installing opencode...' &&
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
which opencode || echo 'ERROR: opencode not in PATH' &&
npm bin -g &&
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc &&
echo 'cd /workspace' >> /root/.bashrc &&
echo 'OpenCode installation complete' &&
cd /workspace &&
exec tail -f /dev/null"
stdin_open: true
tty: true
restart: unless-stopped
volumes:
opencode_home:""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
for tool_data in builtin_types:
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
if not existing:
tool_type = ToolType(
name=tool_data["name"],
display_name=tool_data["display_name"],
description=tool_data["description"],
category=tool_data["category"],
interfaces=tool_data["interfaces"],
definition_type="compose",
compose_template=tool_data["compose_template"],
required_variables=tool_data["required_variables"],
default_port=tool_data.get("default_port"),
is_builtin=True,
)
session.add(tool_type)
logger.info("Created built-in tool type: %s", tool_data["name"])
else:
# Update existing built-in tool types to reflect code changes
existing.display_name = tool_data["display_name"]
existing.description = tool_data["description"]
existing.category = tool_data["category"]
existing.interfaces = tool_data["interfaces"]
existing.definition_type = "compose"
existing.compose_template = tool_data["compose_template"]
existing.required_variables = tool_data["required_variables"]
existing.default_port = tool_data.get("default_port")
logger.info("Updated built-in tool type: %s", tool_data["name"])
await session.commit()
logger.info("Built-in tool types seeded successfully.")
-97
View File
@@ -1,97 +0,0 @@
"""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)
@@ -1,567 +0,0 @@
"""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,
}
-299
View File
@@ -1,299 +0,0 @@
"""Config profile business logic."""
import logging
import uuid
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile
from src.models.tool_type import ToolType
from src.models.user_config import UserConfig
logger = logging.getLogger(__name__)
MAX_INCLUDES_DEPTH = 10
async def get_owned_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> ConfigProfile:
"""Fetch a config profile and verify ownership."""
profile = await session.get(ConfigProfile, profile_id)
if profile is None or profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="config profile not found",
)
return profile
async def _detect_cycle(
session: AsyncSession,
profile_id: uuid.UUID,
visited: set[uuid.UUID] | None = None,
depth: int = 0,
) -> bool:
"""Detect cycles in profile includes using DFS.
Returns True if a cycle is detected.
"""
if depth > MAX_INCLUDES_DEPTH:
return True
if visited is None:
visited = set()
if profile_id in visited:
return True
visited.add(profile_id)
result = await session.execute(
select(ConfigInclude.included_profile_id).where(
ConfigInclude.profile_id == profile_id
)
)
included_ids = result.scalars().all()
for included_id in included_ids:
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
return True
return False
async def validate_includes_no_cycle(
session: AsyncSession,
profile_id: uuid.UUID,
new_included_id: uuid.UUID | None = None,
) -> None:
"""Validate that adding an include wouldn't create a cycle."""
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="adding this include would create a circular reference",
)
# ---------------------------------------------------------------------------
# Profile CRUD helpers
# ---------------------------------------------------------------------------
async def check_duplicate_name(
session: AsyncSession,
user_id: uuid.UUID,
name: str,
exclude_id: uuid.UUID | None = None,
) -> None:
"""Raise 409 if a profile with the given name already exists."""
query = select(ConfigProfile).where(
ConfigProfile.user_id == user_id,
ConfigProfile.name == name,
)
if exclude_id:
query = query.where(ConfigProfile.id != exclude_id)
existing = await session.scalar(query)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config profile with name '{name}' already exists",
)
def profile_to_dict(profile: ConfigProfile) -> dict:
"""Serialize a ConfigProfile to a dict."""
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
# ---------------------------------------------------------------------------
# Include helpers
# ---------------------------------------------------------------------------
async def check_duplicate_include(
session: AsyncSession,
profile_id: uuid.UUID,
included_profile_id: uuid.UUID,
) -> None:
"""Raise 409 if the include already exists."""
existing = await session.scalar(
select(ConfigInclude).where(
ConfigInclude.profile_id == profile_id,
ConfigInclude.included_profile_id == included_profile_id,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="this include already exists",
)
def include_to_dict(inc: ConfigInclude, included_name: str | None) -> dict:
"""Serialize a ConfigInclude to a dict."""
return {
"id": str(inc.id),
"profile_id": str(inc.profile_id),
"included_profile_id": str(inc.included_profile_id),
"included_profile_name": included_name,
"order_index": inc.order_index,
"created_at": inc.created_at.isoformat() if inc.created_at else None,
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
}
# ---------------------------------------------------------------------------
# Mount helpers
# ---------------------------------------------------------------------------
async def check_duplicate_mount_path(
session: AsyncSession,
profile_id: uuid.UUID,
target_path: str,
exclude_id: uuid.UUID | None = None,
) -> None:
"""Raise 409 if a mount with the given path already exists."""
query = select(ConfigMount).where(
ConfigMount.profile_id == profile_id,
ConfigMount.target_path == target_path,
)
if exclude_id:
query = query.where(ConfigMount.id != exclude_id)
existing = await session.scalar(query)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"mount with path '{target_path}' already exists",
)
def mount_to_dict(mount: ConfigMount) -> dict:
"""Serialize a ConfigMount to a dict."""
return {
"id": str(mount.id),
"profile_id": str(mount.profile_id),
"target_path": mount.target_path,
"files": mount.files,
"mode": mount.mode,
"order_index": mount.order_index,
"created_at": mount.created_at.isoformat() if mount.created_at else None,
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
}
# ---------------------------------------------------------------------------
# Default profile helpers
# ---------------------------------------------------------------------------
async def get_or_create_user_config(
session: AsyncSession,
user_id: uuid.UUID,
) -> UserConfig:
"""Get existing user config or create a new one."""
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
user_config = result.scalar_one_or_none()
if user_config is None:
user_config = UserConfig(user_id=user_id, config={})
session.add(user_config)
return user_config
async def validate_default_profiles(
session: AsyncSession,
user_id: uuid.UUID,
default_profiles: dict[str, str],
) -> None:
"""Validate that all profile IDs in default_profiles belong to the user."""
for tool_type_id, profile_id_str in default_profiles.items():
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
if profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"profile {profile_id_str} not found")
if profile.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user")
async def get_default_profiles(
session: AsyncSession,
user_id: uuid.UUID,
) -> dict:
"""Get default profiles for a user."""
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
user_config = result.scalar_one_or_none()
return {"default_profiles": user_config.default_profiles if user_config else {}}
async def set_default_profiles(
session: AsyncSession,
user_id: uuid.UUID,
default_profiles: dict[str, str],
) -> dict:
"""Set default profiles for a user."""
user_config = await get_or_create_user_config(session, user_id)
await validate_default_profiles(session, user_id, default_profiles)
user_config.config = {**user_config.config, "default_profiles": default_profiles}
await session.commit()
await session.refresh(user_config)
return {"default_profiles": user_config.default_profiles}
async def get_default_profile_for_tool_type(
session: AsyncSession,
user_id: uuid.UUID,
tool_type_id: str,
) -> dict:
"""Get default profile for a specific tool type."""
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
user_config = result.scalar_one_or_none()
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
# ---------------------------------------------------------------------------
# Include list helper
# ---------------------------------------------------------------------------
async def list_includes_for_profile(
session: AsyncSession,
profile_id: uuid.UUID,
) -> dict:
"""List all includes for a profile."""
result = await session.execute(
select(ConfigInclude)
.where(ConfigInclude.profile_id == profile_id)
.order_by(ConfigInclude.order_index)
)
includes_data = []
for inc in result.scalars().all():
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
return {"includes": includes_data}
# ---------------------------------------------------------------------------
# Mount list helper
# ---------------------------------------------------------------------------
async def list_mounts_for_profile(
session: AsyncSession,
profile_id: uuid.UUID,
) -> dict:
"""List all mounts for a profile."""
result = await session.execute(
select(ConfigMount)
.where(ConfigMount.profile_id == profile_id)
.order_by(ConfigMount.order_index)
)
return {"mounts": [mount_to_dict(m) for m in result.scalars().all()]}
-32
View File
@@ -1,32 +0,0 @@
"""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)
+226 -254
View File
@@ -1,52 +1,10 @@
"""Docker service for managing tool instances."""
import logging
import os
import subprocess
import time
from collections import Counter
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
"""Sort volume strings so parent paths come before child paths.
Docker Compose mounts volumes in array order. A later mount at a parent
path hides earlier mounts at child paths. By sorting shallow paths first
and deep paths last, deeper (more specific) mounts overlay correctly.
Volume format: source:target or source:target:type
Args:
volumes: List of Docker volume mount strings.
Returns:
Sorted list with parent paths before child paths.
"""
def _target_depth(vol: str) -> int:
parts = vol.split(":")
if len(parts) < 2:
return 0
target = parts[1].rstrip("/")
if not target or target == "/":
return 0
return target.count("/")
# Detect duplicate targets and warn
targets = []
for vol in volumes:
parts = vol.split(":")
targets.append(parts[1] if len(parts) > 1 else "")
dupes = [t for t, c in Counter(targets).items() if c > 1]
if dupes:
logger.warning("Duplicate mount targets detected: %s", dupes)
# Stable sort: parent paths first, child paths last
return sorted(volumes, key=_target_depth)
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
"""Render a Docker Compose template with variable substitution.
@@ -77,7 +35,6 @@ def ensure_instance_directory(instance_id: str, base_path: str | None = None) ->
"""
if base_path is None:
from src.config import Settings
base_path = Settings().instance_base_path
instance_dir = Path(base_path) / instance_id
instance_dir.mkdir(parents=True, exist_ok=True)
@@ -130,11 +87,64 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
raise ValueError(f"File path '{file_path}' escapes instance directory")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
def 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
def execute_compose_command(
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
) -> tuple[int, str, str]:
@@ -152,12 +162,12 @@ def execute_compose_command(
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
if action == "up":
cmd.extend(["up", "-d", "--force-recreate"])
cmd.extend(["up", "-d"])
elif action == "down":
cmd.extend(["down", "-v"])
elif action in ("start", "stop", "restart"):
@@ -179,113 +189,53 @@ def execute_compose_command(
def get_container_id(instance_name: str) -> str | None:
"""Get the container ID for a compose service.
Uses exact name matching to avoid substring collisions with tunnel
containers (e.g. tunnel-code-server-... matching code-server-...).
Falls back to case-insensitive matching since Docker DNS is case-
insensitive but docker inspect is case-sensitive.
Args:
instance_name: The expected container name.
instance_name: The service name in compose
Returns:
Container ID or None if not found.
Container ID or None if not found
"""
expected = instance_name.lower()
# Fast path: exact match via docker inspect
result = subprocess.run(
["docker", "inspect", "-f", "{{.Id}}", expected],
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
# Fallback: list all containers and do case-insensitive exact match
ps_result = subprocess.run(
["docker", "ps", "-a", "--format", "{{.Names}}\t{{.ID}}"],
capture_output=True,
text=True,
)
if ps_result.returncode == 0:
for line in ps_result.stdout.strip().splitlines():
parts = line.split("\t")
if len(parts) == 2:
name, cid = parts
if name.lower() == expected:
return cid
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().split("\n")[0]
return None
def get_container_name(instance_name: str) -> str | None:
"""Get the full container name for a compose service.
Uses exact name matching via docker inspect to avoid substring collisions.
Args:
instance_name: The exact container name (case-insensitive for Docker).
instance_name: The service name in compose
Returns:
Container name or None if not found.
Container name or None if not found
"""
result = subprocess.run(
["docker", "inspect", "-f", "{{.Name}}", instance_name.lower()],
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().lstrip("/")
return result.stdout.strip().split("\n")[0]
return None
def get_backend_network_name() -> str:
"""Auto-detect the actual Docker network name for the backend network.
Docker Compose prefixes network names with the project directory name
(e.g. 'headquarter_backend' instead of 'backend'). We inspect the API
container itself to find the real network name it's connected to.
Returns:
The actual Docker network name, or 'backend' as fallback.
"""
# Try to find the API container by its known name
api_container = "hq-api"
result = subprocess.run(
[
"docker",
"inspect",
"-f",
"{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}",
api_container,
],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
networks = result.stdout.strip().split()
for net in networks:
if "backend" in net.lower():
return net
# API container is on some network — return the first one
return networks[0]
return "backend"
def connect_container_to_network(
container_name: str, network_name: str | None = None
) -> bool:
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
"""Connect a Docker container to an existing network.
Args:
container_name: Name or ID of the container
network_name: Name of the Docker network. If None, auto-detects
from the API container's own network membership.
network_name: Name of the Docker network (default: backend)
Returns:
True if successful, False otherwise
"""
if network_name is None:
network_name = get_backend_network_name()
result = subprocess.run(
["docker", "network", "connect", network_name, container_name],
capture_output=True,
@@ -294,153 +244,24 @@ def connect_container_to_network(
return result.returncode == 0
def get_container_ip_on_network(
container_id: str, network_name: str | None = None
) -> str | None:
"""Get a container's IP address on a specific Docker network.
Args:
container_id: Docker container ID or name.
network_name: Network name. If None, auto-detects from the API container.
Returns:
IP address string, or None if the container is not on that network.
"""
if network_name is None:
network_name = get_backend_network_name()
result = subprocess.run(
[
"docker",
"inspect",
"-f",
f"{{{{.NetworkSettings.Networks.{network_name}.IPAddress}}}}",
container_id,
],
capture_output=True,
text=True,
)
if result.returncode == 0:
ip = result.stdout.strip()
if ip and ip != "<no value>":
return ip
return None
def is_container_on_network(container_id: str, network_name: str | None = None) -> bool:
"""Check whether a container is already attached to a Docker network.
Args:
container_id: Docker container ID or name.
network_name: Network name. If None, auto-detects from the API container.
Returns:
True if the container is on the network.
"""
if network_name is None:
network_name = get_backend_network_name()
result = subprocess.run(
[
"docker",
"inspect",
"-f",
f"{{{{.NetworkSettings.Networks.{network_name}}}}}",
container_id,
],
capture_output=True,
text=True,
)
return result.returncode == 0 and "<no value>" not in result.stdout
def get_container_status(container_id: str) -> dict[str, Any]:
def get_container_status(container_id: str) -> str:
"""Get the status of a Docker container.
Args:
container_id: Docker container ID
Returns:
Dict with 'status' (running, exited, restarting, not_found),
'exit_code' (int or None), and 'health' (health status or None)
Container status string (running, exited, etc.)
"""
result = subprocess.run(
[
"docker",
"inspect",
"-f",
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
container_id,
],
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
capture_output=True,
text=True,
)
if result.returncode != 0:
return {"status": "not_found", "exit_code": None, "health": None}
parts = result.stdout.strip().split("|")
status = parts[0] if parts else "unknown"
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
return {"status": status, "exit_code": exit_code, "health": health}
def wait_for_container_running(
container_id: str, timeout: int = 30, interval: float = 2.0
) -> dict[str, Any]:
"""Wait for a container to reach the running state.
Polls docker inspect until the container status is "running" or timeout.
Args:
container_id: Docker container ID
timeout: Maximum seconds to wait
interval: Seconds between polls
Returns:
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
and 'waited_seconds' (float)
"""
start_time = time.time()
while time.time() - start_time < timeout:
info = get_container_status(container_id)
if info["status"] == "running":
return {
"success": True,
"status": "running",
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
if info["status"] == "exited":
return {
"success": False,
"status": "exited",
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
if info["status"] == "not_found":
return {
"success": False,
"status": "not_found",
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
time.sleep(interval)
# Timeout reached
info = get_container_status(container_id)
return {
"success": False,
"status": info["status"],
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
if result.returncode == 0:
return result.stdout.strip()
return "unknown"
def get_container_logs(container_id: str, tail: int = 100) -> str:
@@ -482,3 +303,154 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
return port
raise RuntimeError(f"No free port found in range {start}-{end}")
import subprocess
import time
import re
def start_cloudflared_tunnel(
container_name: str, port: int, timeout: int = 30
) -> dict[str, str]:
"""Start a temporary Cloudflare tunnel for a container.
Uses 'cloudflared tunnel --url' to create a temporary tunnel
with a random trycloudflare.com URL.
Args:
container_name: Name of the Docker container to tunnel to
port: Port number the container listens on
timeout: Maximum seconds to wait for tunnel URL
Returns:
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
"""
import subprocess
import time
import re
import logging
logger = logging.getLogger(__name__)
# First verify the container is accessible
logger.info("Checking connectivity to %s:%d...", container_name, port)
for attempt in range(10):
check = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
f"http://{container_name}:{port}"],
capture_output=True,
text=True,
timeout=5,
)
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
if check.returncode == 0:
break
time.sleep(1)
else:
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
# Run cloudflared in background, capture output
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
proc = subprocess.Popen(
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
# Wait for the URL to appear in output
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
start_time = time.time()
url = None
while time.time() - start_time < timeout:
# Read available output
import select
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
if readable:
line = proc.stdout.readline()
if line:
match = url_pattern.search(line)
if match:
url = match.group(0)
break
if not url:
proc.terminate()
proc.wait(timeout=5)
raise RuntimeError(
f"Failed to get tunnel URL within {timeout}s. "
f"cloudflared output may contain errors."
)
return {"url": url, "pid": str(proc.pid)}
def stop_cloudflared_tunnel(pid: str) -> None:
"""Stop a cloudflared tunnel process.
Args:
pid: Process ID of the cloudflared tunnel
"""
import os
import signal
try:
os.kill(int(pid), signal.SIGTERM)
except ProcessLookupError:
pass # Already stopped
def recreate_tunnel(
container_name: str, port: int, old_pid: str | None = None
) -> dict[str, str]:
"""Recreate a temporary Cloudflare tunnel.
Stops the old tunnel (if pid provided) and starts a new one.
Args:
container_name: Name of the Docker container to tunnel to
port: Port number the container listens on
old_pid: Optional PID of the old tunnel process to stop
Returns:
Dict with 'url' and 'pid' for the new tunnel
"""
if old_pid:
stop_cloudflared_tunnel(old_pid)
return start_cloudflared_tunnel(container_name, port)
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
"""Check if a tunnel URL is healthy.
Args:
url: The tunnel URL to check
timeout: Request timeout in seconds
Returns:
Dict with 'healthy' (bool) and 'status_code' (int or None)
"""
import subprocess
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())
return {
"healthy": 200 <= status_code < 400,
"status_code": status_code,
}
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
return {
"healthy": False,
"status_code": None,
"error": str(e),
}
-44
View File
@@ -1,44 +0,0 @@
"""Docker services for container and tunnel management."""
from .compose import (
ensure_instance_directory,
execute_compose_command,
render_compose_template,
write_compose_file,
write_env_file,
)
from .config_staging import write_config_files, write_config_folder_files
from .container import (
connect_container_to_network,
find_free_port,
get_container_id,
get_container_logs,
get_container_name,
get_container_status,
)
from .tunnel import (
check_tunnel_health,
recreate_tunnel,
start_cloudflared_tunnel,
stop_cloudflared_tunnel,
)
__all__ = [
"render_compose_template",
"ensure_instance_directory",
"write_compose_file",
"write_env_file",
"execute_compose_command",
"write_config_files",
"write_config_folder_files",
"get_container_id",
"get_container_name",
"connect_container_to_network",
"get_container_status",
"get_container_logs",
"find_free_port",
"start_cloudflared_tunnel",
"stop_cloudflared_tunnel",
"recreate_tunnel",
"check_tunnel_health",
]
-237
View File
@@ -1,237 +0,0 @@
"""Docker Compose file generation and command execution."""
import re
import subprocess
import uuid
from pathlib import Path
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.config_profile import ConfigProfile
from src.models.tool_instance import ToolInstance
from src.services.profile_resolver import resolve_profile
def _sanitize_name(name: str) -> str:
"""Sanitize a string for use in Docker/container names."""
sanitized = re.sub(r"[^a-z0-9-]", "-", name.lower())
sanitized = re.sub(r"-+", "-", sanitized)
return sanitized.strip("-")
async def _generate_instance_name(
session: AsyncSession,
project_name: str,
tool_type_name: str,
) -> str:
"""Generate a unique instance name: project-tool-NUM."""
base = f"{_sanitize_name(project_name)}-{_sanitize_name(tool_type_name)}"
base = base.strip("-") or "instance"
result = await session.execute(
select(ToolInstance.name).where(ToolInstance.name.like(f"{base}-%"))
)
names = result.scalars().all()
max_num = 0
for name in names:
parts = name.rsplit("-", 1)
if len(parts) == 2 and parts[0] == base and parts[1].isdigit():
max_num = max(max_num, int(parts[1]))
return f"{base}-{max_num + 1:03d}"
def _modify_compose_file(
compose_path: str,
port_override: int | None = None,
start_command: str | None = None,
working_directory: str | None = None,
extra_volumes: list[dict] | None = None,
) -> None:
"""Modify compose file with runtime overrides."""
import yaml
compose_file = Path(compose_path)
content = compose_file.read_text()
compose_data = yaml.safe_load(content)
if not compose_data or "services" not in compose_data:
return
for service_name, service_config in compose_data["services"].items():
if port_override and "ports" in service_config:
for i, port_mapping in enumerate(service_config["ports"]):
if isinstance(port_mapping, str) and ":" in port_mapping:
_host_port, container_port = port_mapping.split(":", 1)
service_config["ports"][i] = f"{port_override}:{container_port}"
break
if start_command:
service_config["command"] = start_command
if working_directory:
service_config["working_dir"] = working_directory
if extra_volumes:
if "volumes" not in service_config:
service_config["volumes"] = []
for vol in extra_volumes:
source = vol.get("source", "")
target = vol.get("target", "")
vol_type = vol.get("type", "bind")
if vol_type == "bind":
service_config["volumes"].append(f"{source}:{target}")
else:
service_config["volumes"].append(f"{source}:{target}:{vol_type}")
break
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
async def _apply_resolved_profile(
profile: ConfigProfile,
instance_dir: str,
env_vars: dict[str, str],
port_override: int | None,
start_command: str | None,
working_directory: str | None,
extra_volumes: list[dict],
) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]:
"""Resolve a profile and apply its output to instance configuration."""
resolved = resolve_profile(profile)
if resolved.environment_variables:
env_vars.update(resolved.environment_variables)
if resolved.runtime_hints.start_command is not None:
start_command = resolved.runtime_hints.start_command
if resolved.runtime_hints.working_directory is not None:
working_directory = resolved.runtime_hints.working_directory
if resolved.runtime_hints.port is not None:
port_override = resolved.runtime_hints.port
for target_path, mount in resolved.mounts.items():
safe_name = target_path.strip("/").replace("/", "_")
mount_dir = Path(instance_dir) / "mounts" / safe_name
mount_dir.mkdir(parents=True, exist_ok=True)
for rel_path, content in mount.files.items():
file_path = mount_dir / rel_path
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
extra_volumes.append({
"source": str(mount_dir),
"target": target_path,
"type": mount.mode,
})
return env_vars, port_override, start_command, working_directory, extra_volumes
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
"""Render a Docker Compose template with variable substitution.
Args:
template: The compose template string
variables: Dictionary of variable names to values
Returns:
Rendered compose file content
"""
result = template
for key, value in variables.items():
placeholder = f"{{{{{key}}}}}"
result = result.replace(placeholder, str(value))
return result
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
"""Create and return the instance directory path.
Args:
instance_id: Unique instance identifier
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
Returns:
Absolute path to instance directory
"""
if base_path is None:
from src.config import Settings
base_path = Settings().instance_base_path
instance_dir = Path(base_path) / instance_id
instance_dir.mkdir(parents=True, exist_ok=True)
return str(instance_dir.absolute())
def write_compose_file(instance_dir: str, content: str) -> str:
"""Write the rendered compose file to the instance directory.
Args:
instance_dir: Path to instance directory
content: Rendered compose content
Returns:
Path to the compose file
"""
compose_path = Path(instance_dir) / "docker-compose.yml"
compose_path.write_text(content)
return str(compose_path)
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
"""Write environment variables to a .env file.
Args:
instance_dir: Path to instance directory
env_vars: Dictionary of env var names to values
Returns:
Path to the env file
"""
env_path = Path(instance_dir) / ".env"
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
env_path.write_text("\n".join(lines) + "\n")
return str(env_path)
def execute_compose_command(
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
) -> tuple[int, str, str]:
"""Execute a docker compose command.
Args:
compose_path: Path to docker-compose.yml
action: The compose action (up, down, start, stop, restart)
timeout: Command timeout in seconds
env_file: Optional path to .env file for environment variables
Returns:
Tuple of (returncode, stdout, stderr)
"""
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
if action == "up":
cmd.extend(["up", "-d"])
elif action == "down":
cmd.extend(["down", "-v"])
elif action in ("start", "stop", "restart"):
cmd.append(action)
else:
raise ValueError(f"Unknown compose action: {action}")
result = subprocess.run(
cmd,
cwd=str(instance_dir),
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
@@ -1,79 +0,0 @@
"""Config folder file staging for Docker instances."""
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
"""Write config files to the instance directory.
Args:
instance_dir: Path to instance directory
files: Dictionary of file paths (relative to instance dir) to content
"""
instance_path = Path(instance_dir)
for file_path, content in files.items():
# Ensure the path is within the instance directory (security)
full_path = instance_path / file_path
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
raise ValueError(f"File path '{file_path}' escapes instance directory")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
def 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
-121
View File
@@ -1,121 +0,0 @@
"""Docker container lifecycle and query operations."""
import socket
import subprocess
def get_container_id(instance_name: str) -> str | None:
"""Get the container ID for a compose service.
Args:
instance_name: The service name in compose
Returns:
Container ID or None if not found
"""
result = subprocess.run(
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().split("\n")[0]
return None
def get_container_name(instance_name: str) -> str | None:
"""Get the full container name for a compose service.
Args:
instance_name: The service name in compose
Returns:
Container name or None if not found
"""
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().split("\n")[0]
return None
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
"""Connect a Docker container to an existing network.
Args:
container_name: Name or ID of the container
network_name: Name of the Docker network (default: backend)
Returns:
True if successful, False otherwise
"""
result = subprocess.run(
["docker", "network", "connect", network_name, container_name],
capture_output=True,
text=True,
)
return result.returncode == 0
def get_container_status(container_id: str) -> str:
"""Get the status of a Docker container.
Args:
container_id: Docker container ID
Returns:
Container status string (running, exited, etc.)
"""
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout.strip()
return "unknown"
def get_container_logs(container_id: str, tail: int = 100) -> str:
"""Get the logs of a Docker container.
Args:
container_id: Docker container ID
tail: Number of lines to return
Returns:
Container logs
"""
result = subprocess.run(
["docker", "logs", "--tail", str(tail), container_id],
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout
return f"Failed to get logs: {result.stderr}"
def find_free_port(start: int = 10000, end: int = 20000) -> int:
"""Find a free TCP port in the given range.
Args:
start: Start of port range
end: End of port range
Returns:
Free port number
"""
for port in range(start, end):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
if s.connect_ex(("localhost", port)) != 0:
return port
raise RuntimeError(f"No free port found in range {start}-{end}")
-146
View File
@@ -1,146 +0,0 @@
"""Cloudflare tunnel management for Docker instances."""
import logging
import os
import re
import signal
import subprocess
import time
from typing import Any
logger = logging.getLogger(__name__)
def start_cloudflared_tunnel(
container_name: str, port: int, timeout: int = 30
) -> dict[str, str]:
"""Start a temporary Cloudflare tunnel for a container.
Uses 'cloudflared tunnel --url' to create a temporary tunnel
with a random trycloudflare.com URL.
Args:
container_name: Name of the Docker container to tunnel to
port: Port number the container listens on
timeout: Maximum seconds to wait for tunnel URL
Returns:
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
"""
import select as sel
# First verify the container is accessible
logger.info("Checking connectivity to %s:%d...", container_name, port)
for attempt in range(10):
check = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
f"http://{container_name}:{port}"],
capture_output=True,
text=True,
timeout=5,
)
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
if check.returncode == 0:
break
time.sleep(1)
else:
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
# Run cloudflared in background, capture output
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
proc = subprocess.Popen(
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
# Wait for the URL to appear in output
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
start_time = time.time()
url = None
while time.time() - start_time < timeout:
# Read available output
readable, _, _ = sel.select([proc.stdout], [], [], 1.0)
if readable:
line = proc.stdout.readline()
if line:
match = url_pattern.search(line)
if match:
url = match.group(0)
break
if not url:
proc.terminate()
proc.wait(timeout=5)
raise RuntimeError(
f"Failed to get tunnel URL within {timeout}s. "
f"cloudflared output may contain errors."
)
return {"url": url, "pid": str(proc.pid)}
def stop_cloudflared_tunnel(pid: str) -> None:
"""Stop a cloudflared tunnel process.
Args:
pid: Process ID of the cloudflared tunnel
"""
try:
os.kill(int(pid), signal.SIGTERM)
except ProcessLookupError:
pass # Already stopped
def recreate_tunnel(
container_name: str, port: int, old_pid: str | None = None
) -> dict[str, str]:
"""Recreate a temporary Cloudflare tunnel.
Stops the old tunnel (if pid provided) and starts a new one.
Args:
container_name: Name of the Docker container to tunnel to
port: Port number the container listens on
old_pid: Optional PID of the old tunnel process to stop
Returns:
Dict with 'url' and 'pid' for the new tunnel
"""
if old_pid:
stop_cloudflared_tunnel(old_pid)
return start_cloudflared_tunnel(container_name, port)
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
"""Check if a tunnel URL is healthy.
Args:
url: The tunnel URL to check
timeout: Request timeout in seconds
Returns:
Dict with 'healthy' (bool) and 'status_code' (int or None)
"""
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())
return {
"healthy": 200 <= status_code < 400,
"status_code": status_code,
}
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
return {
"healthy": False,
"status_code": None,
"error": str(e),
}
-97
View File
@@ -1,97 +0,0 @@
"""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},
)
-128
View File
@@ -1,128 +0,0 @@
"""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
-1
View File
@@ -1 +0,0 @@
"""Git services package."""

Some files were not shown because too many files have changed in this diff Show More