feat: initial commit - pipeline runner with real-time streaming

This commit is contained in:
2026-07-03 09:31:45 +03:00
commit 5a29468b7e
9 changed files with 1000 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg
*.egg-info/
dist/
build/
*.whl
# Virtual environments
venv/
.venv/
env/
.env/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# dotenv
.env
.env.*
!env.example
# Logs
*.log
logs/
# Jupyter
.ipynb_checkpoints/
# Coverage
htmlcov/
.coverage
.coverage.*
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pytest
.pytest_cache/
# Ruff
.ruff_cache/
+26
View File
@@ -0,0 +1,26 @@
# AGENTS.md
## Repo layout
- `pipeline-runner/` — single Python package (FastAPI + uvicorn). All code lives here.
- `pipeline-runner/app.py` — FastAPI app entrypoint with REST API + WebSocket + static file serving.
- `pipeline-runner/main.py` — uvicorn dev server launcher (`python main.py`).
- `pipeline-runner/static/index.html` — frontend UI.
- `pipeline-runner/storage/pipelines/` — JSON files for persisted pipelines.
- `pipeline-runner/storage/logs/` — execution logs (one file per run).
## Dev commands
- Install deps: `cd pipeline-runner && pip install -r requirements.txt`
- Start dev server: `cd pipeline-runner && python main.py` (uvicorn with reload, port 8000)
- Deploy with auto-reload: `cd pipeline-runner && ./deploy.sh` (activates venv, installs deps, starts uvicorn with `--reload`)
- Run tests: `cd pipeline-runner && python -m pytest`
- Tests use `httpx`-backed `TestClient` from FastAPI.
- Tests that exercise pipeline execution (`TestExecutePipeline`) monkeypatch `asyncio.create_subprocess_exec` to avoid calling `opencode` — they do not require `opencode` to be installed.
- The `clean_storage` fixture auto-removes `storage/pipelines/` and `storage/logs/` before/after each test.
## Key behaviors
- Pipeline execution runs `opencode run <prompt> --title <title>` per step via `asyncio.create_subprocess_exec`.
- Pipeline stops on first step error (non-zero return code).
- Real-time logs are streamed over WebSocket at `/ws/{run_id}`.
- CORS is open (`allow_origins=["*"]`) for dev convenience.
- No lint, typecheck, or formatter tooling is configured.
+49
View File
@@ -0,0 +1,49 @@
# Pipeline Runner
Веб-интерфейс для создания и выполнения pipeline промптов через `opencode run`.
## Установка
```bash
cd pipeline-runner
pip install -r requirements.txt
```
## Запуск
```bash
python main.py
```
Откройте http://localhost:8000 в браузере.
### Авто-релоад
```bash
./deploy.sh
```
Скрипт активирует виртуальное окружение, установит зависимости и запустит uvicorn с авто-релоадом — сервер перезапустится автоматически после изменений в коде.
## Функционал
1. **Создание Pipeline**:
- Добавьте промпты с текстом и заголовком
- Сохраните pipeline с именем
2. **Запуск**:
- Выберите сохраненный pipeline
- Нажмите "Run Pipeline"
- Следите за выполнением в реальном времени через WebSocket
3. **Результаты**:
- Каждый шаг отображается в отдельной панели
- Статусы: pending, running, completed, error
- Логи сохраняются в `storage/logs/` (один файл на запуск)
## Структура
- `/api/pipelines` - REST API для управления pipeline
- `/ws/{run_id}` - WebSocket для real-time логов
- `storage/pipelines/` - JSON файлы сохраненных pipeline
- `storage/logs/` - Логи выполнений
+226
View File
@@ -0,0 +1,226 @@
import asyncio
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
app = FastAPI(title="Pipeline Runner")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
BASE_DIR = Path(__file__).parent
PIPELINES_DIR = BASE_DIR / "storage" / "pipelines"
LOGS_DIR = BASE_DIR / "storage" / "logs"
PIPELINES_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
active_connections: dict[str, set[WebSocket]] = {}
active_runs: dict[str, asyncio.Task] = {}
class Prompt(BaseModel):
id: str
text: str
title: str
order: int
class Pipeline(BaseModel):
id: str
name: str
prompts: list[Prompt]
created_at: str
updated_at: str
def load_pipelines() -> list[Pipeline]:
pipelines = []
for f in PIPELINES_DIR.glob("*.json"):
try:
with open(f) as file:
data = json.load(file)
pipelines.append(Pipeline(**data))
except (json.JSONDecodeError, OSError):
continue
return sorted(pipelines, key=lambda x: x.updated_at, reverse=True)
def save_pipeline(pipeline: Pipeline):
pipeline.updated_at = datetime.now(timezone.utc).isoformat()
try:
with open(PIPELINES_DIR / f"{pipeline.id}.json", "w") as f:
json.dump(pipeline.model_dump(), f, indent=2)
except OSError as e:
raise HTTPException(status_code=500, detail=f"Failed to save pipeline: {e}")
def delete_pipeline(pipeline_id: str):
path = PIPELINES_DIR / f"{pipeline_id}.json"
if path.exists():
try:
path.unlink()
except OSError as e:
raise HTTPException(status_code=500, detail=f"Failed to delete pipeline: {e}")
@app.get("/api/pipelines", response_model=list[Pipeline])
async def list_pipelines():
return load_pipelines()
@app.post("/api/pipelines", response_model=Pipeline)
async def create_pipeline(pipeline: Pipeline):
existing = load_pipelines()
if any(p.id == pipeline.id for p in existing):
raise HTTPException(status_code=409, detail="Pipeline already exists")
save_pipeline(pipeline)
return pipeline
@app.delete("/api/pipelines/{pipeline_id}")
async def delete(pipeline_id: str):
pipelines = load_pipelines()
if not any(p.id == pipeline_id for p in pipelines):
raise HTTPException(status_code=404, detail="Pipeline not found")
delete_pipeline(pipeline_id)
return {"deleted": pipeline_id}
@app.post("/api/pipelines/{pipeline_id}/execute")
async def execute_pipeline(pipeline_id: str):
pipelines = load_pipelines()
pipeline = next((p for p in pipelines if p.id == pipeline_id), None)
if not pipeline:
raise HTTPException(status_code=404, detail="Pipeline not found")
run_id = str(uuid.uuid4())
log_file = LOGS_DIR / f"{run_id}.log"
active_connections[run_id] = set()
task = asyncio.create_task(run_pipeline_task(pipeline, run_id, log_file))
active_runs[run_id] = task
return {"run_id": run_id, "log_file": str(log_file)}
async def run_pipeline_task(pipeline: Pipeline, run_id: str, log_file: Path):
async def write_log(line: str):
with open(log_file, "a") as f:
f.write(f"[{datetime.now(timezone.utc).isoformat()}] {line}\n")
for ws in list(active_connections.get(run_id, set())):
try:
await ws.send_json({"type": "log", "data": line})
except:
pass
async def stream_reader(stream, prefix, output_collector):
while True:
line = await stream.readline()
if not line:
break
text = line.decode("utf-8", errors="replace").rstrip("\n")
if text:
output_collector.append(text)
await write_log(f"{prefix} {text}")
await write_log(f"Starting pipeline: {pipeline.name} (run_id: {run_id})")
try:
for i, prompt in enumerate(pipeline.prompts, 1):
await write_log(f"[Step {i}/{len(pipeline.prompts)}] Starting: {prompt.title}")
await write_log(f"[Step {i}/{len(pipeline.prompts)}] Prompt: {prompt.text}")
for ws in list(active_connections.get(run_id, set())):
try:
await ws.send_json({
"type": "status",
"step_index": i - 1,
"status": "running",
"title": prompt.title
})
except:
pass
cmd = ["opencode", "run", prompt.text, "--title", prompt.title]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout_lines: list[str] = []
stderr_lines: list[str] = []
await asyncio.gather(
stream_reader(process.stdout, f"[Step {i}/{len(pipeline.prompts)}]", stdout_lines),
stream_reader(process.stderr, f"[Step {i}/{len(pipeline.prompts)}]", stderr_lines)
)
await process.wait()
output = "\n".join(stdout_lines)
error = "\n".join(stderr_lines)
status = "completed" if process.returncode == 0 else "error"
result = {"returncode": process.returncode, "output": output, "error": error}
await write_log(f"[Step {i}/{len(pipeline.prompts)}] {status.upper()} (returncode: {process.returncode})")
for ws in list(active_connections.get(run_id, set())):
try:
await ws.send_json({
"type": "status",
"step_index": i - 1,
"status": status,
"title": prompt.title,
"result": result
})
except:
pass
if process.returncode != 0:
await write_log(f"Pipeline failed at step {i}")
break
await write_log("Pipeline finished")
finally:
if run_id in active_runs:
del active_runs[run_id]
@app.websocket("/ws/{run_id}")
async def websocket_endpoint(websocket: WebSocket, run_id: str):
await websocket.accept()
if run_id not in active_connections:
active_connections[run_id] = set()
active_connections[run_id].add(websocket)
try:
while True:
await websocket.receive_text()
except Exception:
active_connections[run_id].discard(websocket)
if not active_connections[run_id]:
del active_connections[run_id]
if (BASE_DIR / "static").exists():
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
async def root():
return {"message": "Pipeline Runner API", "ws_endpoint": "/ws/{run_id}"}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
cd "$(dirname "$0")"
# Activate virtual environment
source .venv/bin/activate
# Install dependencies
pip install -q -r requirements.txt
# Start uvicorn with auto-reload
uvicorn app:app --host 0.0.0.0 --port 8000 --reload
+4
View File
@@ -0,0 +1,4 @@
import uvicorn
if __name__ == "__main__":
uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)
+7
View File
@@ -0,0 +1,7 @@
fastapi>=0.115.0
uvicorn[standard]>=0.34.0
websockets>=14.0
# Testing
pytest>=8.0
httpx2>=0.28.0
+432
View File
@@ -0,0 +1,432 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pipeline Runner</title>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js" defer></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e; color: #eaeaea; padding: 20px; line-height: 1.6;
}
.container { max-width: 1200px; margin: 0 auto; }
h1 { margin-bottom: 20px; color: #00d9ff; }
h2 { margin: 20px 0 10px; color: #00ff88; font-size: 1.2em; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.panel {
background: #16213e; border-radius: 8px; padding: 20px;
border: 1px solid #0f3460;
}
.full-width { grid-column: 1 / -1; }
input, button {
padding: 10px 15px; border-radius: 6px; border: 1px solid #0f3460;
background: #0f3460; color: white; font-size: 14px;
}
input:focus { outline: none; border-color: #00d9ff; }
button {
cursor: pointer; transition: all 0.2s; font-weight: 600;
}
button:hover { transform: translateY(-1px); }
.btn-primary { background: #00d9ff; color: #1a1a2e; border: none; }
.btn-primary:hover { background: #00b8d9; }
.btn-danger { background: #ff4757; color: white; border: none; }
.btn-danger:hover { background: #ff3333; }
.btn-success { background: #00ff88; color: #1a1a2e; border: none; }
.btn-success:hover { background: #00cc6a; }
.btn-secondary { background: #0f3460; border: 1px solid #00d9ff; }
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; color: #00d9ff; font-size: 0.9em; }
.form-group input { width: 100%; }
.pipeline-list { list-style: none; }
.pipeline-item {
background: #0f3460; padding: 12px 15px; margin: 8px 0;
border-radius: 6px; display: flex; justify-content: space-between;
align-items: center; cursor: pointer; transition: all 0.2s;
}
.pipeline-item:hover { background: #1a4a7a; }
.pipeline-item.selected { border: 2px solid #00d9ff; }
.prompt-list { list-style: none; margin: 10px 0; }
.prompt-item {
background: #0f3460; padding: 12px; margin: 6px 0;
border-radius: 6px; display: flex; gap: 10px; align-items: flex-start;
}
.prompt-item .order { color: #00d9ff; font-weight: bold; min-width: 25px; }
.prompt-item .content { flex: 1; }
.prompt-item .title { color: #00ff88; font-weight: 600; margin-bottom: 4px; }
.prompt-item .text { color: #ccc; font-size: 0.9em; word-break: break-word; }
.prompt-item .actions {
display: flex; gap: 5px; margin-left: 10px;
}
.prompt-item .actions button {
padding: 5px 10px; font-size: 12px;
}
.status-badge {
padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;
text-transform: uppercase;
}
.status-pending { background: #555; color: white; }
.status-running { background: #00d9ff; color: #1a1a2e; }
.status-completed { background: #00ff88; color: #1a1a2e; }
.status-error { background: #ff4757; color: white; }
.results-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px;
margin-top: 15px;
}
.result-card {
background: #0f3460; border-radius: 8px; overflow: hidden;
border: 1px solid #16213e;
}
.result-header {
padding: 10px 15px; background: #16213e; display: flex;
justify-content: space-between; align-items: center; cursor: pointer;
}
.result-header:hover { background: #1a2744; }
.result-title { font-weight: 600; color: #00d9ff; }
.result-body {
padding: 15px; max-height: 300px; overflow-y: auto;
font-family: 'Monaco', 'Menlo', monospace; font-size: 12px;
white-space: pre-wrap; word-break: break-word;
}
.result-body .log-line { margin: 2px 0; }
.result-body .log-line.error { color: #ff4757; }
.result-body .log-line.info { color: #00d9ff; }
.result-body .log-line.success { color: #00ff88; }
.result-body .log-line.default { color: #ccc; }
.empty-state {
text-align: center; padding: 40px; color: #666;
}
.run-info {
background: #0f3460; padding: 10px 15px; border-radius: 6px;
margin-bottom: 15px; font-size: 0.9em;
}
.run-info span { color: #00d9ff; }
.close-btn { background: none; border: none; color: #ff4757; cursor: pointer; font-size: 18px; padding: 0; }
</style>
</head>
<body>
<div x-data="app()" class="container">
<h1>🚀 Pipeline Runner</h1>
<div class="grid">
<!-- Left Column: Pipeline Management -->
<div>
<div class="panel">
<h2>Create/Load Pipeline</h2>
<div class="form-group">
<label>Pipeline Name</label>
<input type="text" x-model="newPipelineName" placeholder="My Pipeline">
</div>
<div class="form-group" style="display: flex; gap: 10px;">
<input type="text" x-model="newPromptText" placeholder="Enter prompt text..." style="flex: 1;">
<input type="text" x-model="newPromptTitle" placeholder="Title" style="flex: 0.5;">
</div>
<button @click="addPrompt" class="btn-secondary" style="width: 100%; margin-bottom: 15px;">
+ Add Prompt
</button>
<ul class="prompt-list">
<template x-for="(prompt, index) in prompts" :key="prompt.id">
<li class="prompt-item">
<span class="order" x-text="index + 1"></span>
<div class="content">
<div class="title" x-text="prompt.title"></div>
<div class="text" x-text="prompt.text"></div>
</div>
<div class="actions">
<button @click="removePrompt(index)" class="btn-danger">×</button>
</div>
</li>
</template>
<li x-show="prompts.length === 0" class="empty-state" style="padding: 20px;">
No prompts added yet
</li>
</ul>
<div style="display: flex; gap: 10px; margin-top: 15px;">
<button @click="savePipeline" class="btn-primary" style="flex: 1;">
💾 Save Pipeline
</button>
<button @click="clearPrompts" class="btn-secondary" style="flex: 1;">
🗑️ Clear
</button>
</div>
</div>
<div class="panel" style="margin-top: 20px;">
<h2>Saved Pipelines</h2>
<ul class="pipeline-list">
<template x-for="pipeline in pipelines" :key="pipeline.id">
<li class="pipeline-item"
:class="{ selected: selectedPipeline?.id === pipeline.id }"
@click="loadPipeline(pipeline)">
<div>
<strong x-text="pipeline.name"></strong>
<div style="font-size: 0.85em; color: #888; margin-top: 3px;">
<span x-text="pipeline.prompts.length"></span> prompts
</div>
</div>
<button @click.stop="deletePipeline(pipeline.id)" class="btn-danger" style="padding: 5px 10px;">
Delete
</button>
</li>
</template>
<li x-show="pipelines.length === 0" class="empty-state" style="padding: 20px;">
No saved pipelines
</li>
</ul>
</div>
</div>
<!-- Right Column: Execution -->
<div>
<div class="panel">
<h2>Execution</h2>
<div x-show="!currentRun" class="full-width">
<div class="run-info">
Selected: <span x-text="selectedPipeline?.name || 'None'">None</span>
</div>
<button @click="runPipeline"
class="btn-success"
style="width: 100%; padding: 15px; font-size: 16px;"
:disabled="!selectedPipeline">
▶️ Run Pipeline
</button>
</div>
<div x-show="currentRun" class="full-width">
<div class="run-info">
Run ID: <span x-text="currentRun.run_id"></span> |
Status: <span x-text="runStatus"></span>
</div>
<button @click="stopRun" class="btn-danger" style="width: 100%;">
⏹️ Stop & Close
</button>
<div class="results-grid">
<template x-for="(step, index) in steps" :key="index">
<div class="result-card">
<div class="result-header" @click="step.expanded = !step.expanded">
<span class="result-title" x-text="step.title || 'Step ' + (index + 1)"></span>
<span class="status-badge"
:class="'status-' + (step.status || 'pending')"
x-text="step.status || 'pending'">
</span>
</div>
<div class="result-body" x-show="step.expanded">
<template x-for="line in step.logs" :key="line.id">
<div class="log-line" :class="line.type" x-text="line.text"></div>
</template>
<div x-show="step.logs.length === 0" style="color: #666; font-style: italic;">
No output yet...
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function app() {
return {
newPipelineName: '',
newPromptText: '',
newPromptTitle: '',
prompts: [],
pipelines: [],
selectedPipeline: null,
currentRun: null,
steps: [],
ws: null,
init() {
this.loadPipelines();
this.$watch('prompts', () => {
this.prompts = this.prompts.map((p, i) => ({
...p,
order: i
}));
});
},
async loadPipelines() {
const res = await fetch('/api/pipelines');
this.pipelines = await res.json();
},
addPrompt() {
if (!this.newPromptText.trim() || !this.newPromptTitle.trim()) return;
this.prompts.push({
id: crypto.randomUUID(),
text: this.newPromptText,
title: this.newPromptTitle,
order: this.prompts.length
});
this.newPromptText = '';
this.newPromptTitle = '';
},
removePrompt(index) {
this.prompts.splice(index, 1);
},
clearPrompts() {
this.prompts = [];
this.newPipelineName = '';
},
async savePipeline() {
if (!this.newPipelineName.trim()) {
alert('Enter pipeline name');
return;
}
if (this.prompts.length === 0) {
alert('Add at least one prompt');
return;
}
const pipeline = {
id: crypto.randomUUID(),
name: this.newPipelineName,
prompts: this.prompts,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
await fetch('/api/pipelines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pipeline)
});
this.loadPipelines();
this.clearPrompts();
},
async deletePipeline(id) {
if (!confirm('Delete this pipeline?')) return;
await fetch(`/api/pipelines/${id}`, { method: 'DELETE' });
this.loadPipelines();
if (this.selectedPipeline?.id === id) {
this.selectedPipeline = null;
}
},
loadPipeline(pipeline) {
this.selectedPipeline = pipeline;
this.prompts = [...pipeline.prompts];
},
async runPipeline() {
if (!this.selectedPipeline) return;
const res = await fetch(`/api/pipelines/${this.selectedPipeline.id}/execute`, {
method: 'POST'
});
const data = await res.json();
this.currentRun = data;
this.steps = Array(this.selectedPipeline.prompts.length).fill(null).map((_, i) => ({
title: this.selectedPipeline.prompts[i].title,
status: 'pending',
logs: [],
expanded: true
}));
this.connectWebSocket(data.run_id);
},
connectWebSocket(runId) {
const wsUrl = `ws://${location.host}/ws/${runId}`;
this.ws = new WebSocket(wsUrl);
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'status') {
if (msg.step_index < this.steps.length) {
this.steps[msg.step_index] = {
...this.steps[msg.step_index],
status: msg.status,
result: msg.result
};
if (msg.result?.output) {
this.steps[msg.step_index].logs = [
...this.steps[msg.step_index].logs,
...this.parseOutput(msg.result.output)
];
}
if (msg.result?.error) {
this.steps[msg.step_index].logs = [
...this.steps[msg.step_index].logs,
...this.parseOutput(msg.result.error, 'error')
];
}
}
} else if (msg.type === 'log') {
const lines = msg.data.split('\n').filter(l => l.trim());
lines.forEach(line => {
const stepMatch = line.match(/\[Step (\d+)\/\d+\]/);
const stepIndex = stepMatch ? parseInt(stepMatch[1]) - 1 : -1;
if (stepIndex >= 0 && stepIndex < this.steps.length) {
this.steps[stepIndex].logs.push({
id: crypto.randomUUID(),
text: line,
type: this.getLogType(line)
});
}
});
}
};
this.ws.onclose = () => {
console.log('WebSocket closed');
};
},
parseOutput(text, type = 'default') {
return text.split('\n').filter(l => l.trim()).map(line => ({
id: crypto.randomUUID(),
text: line,
type: type
}));
},
getLogType(line) {
if (line.includes('Error:') || line.includes('error')) return 'error';
if (line.includes('Starting') || line.includes('Output:')) return 'info';
if (line.includes('completed') || line.includes('finished')) return 'success';
return 'default';
},
get runStatus() {
const statuses = this.steps.map(s => s.status).filter(Boolean);
if (statuses.includes('running')) return 'Running...';
if (statuses.includes('error')) return 'Failed';
if (statuses.length === this.steps.length && statuses.every(s => s === 'completed')) {
return 'Completed';
}
return 'In Progress';
},
stopRun() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.currentRun = null;
this.steps = [];
}
};
}
</script>
</body>
</html>
+189
View File
@@ -0,0 +1,189 @@
import json
import shutil
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app import app, PIPELINES_DIR, LOGS_DIR
BASE_DIR = Path(__file__).parent.parent
@pytest.fixture(autouse=True)
def clean_storage():
"""Clean storage before and after each test."""
if PIPELINES_DIR.exists():
shutil.rmtree(PIPELINES_DIR)
if LOGS_DIR.exists():
shutil.rmtree(LOGS_DIR)
PIPELINES_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
yield
if PIPELINES_DIR.exists():
shutil.rmtree(PIPELINES_DIR)
if LOGS_DIR.exists():
shutil.rmtree(LOGS_DIR)
def make_pipeline(name="test", prompt_count=1):
return {
"id": "test-pipeline-id",
"name": name,
"prompts": [
{
"id": f"prompt-{i}",
"text": f"test prompt {i}",
"title": f"Test Prompt {i}",
"order": i
}
for i in range(prompt_count)
],
"created_at": "2024-01-01T00:00:00+00:00",
"updated_at": "2024-01-01T00:00:00+00:00"
}
@pytest.fixture
def client():
return TestClient(app)
class TestListPipelines:
def test_empty(self, client):
resp = client.get("/api/pipelines")
assert resp.status_code == 200
assert resp.json() == []
def test_with_pipelines(self, client):
p = make_pipeline()
client.post("/api/pipelines", json=p)
resp = client.get("/api/pipelines")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["name"] == "test"
class TestCreatePipeline:
def test_create(self, client):
p = make_pipeline()
resp = client.post("/api/pipelines", json=p)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "test"
assert data["id"] == "test-pipeline-id"
def test_duplicate_id(self, client):
p = make_pipeline()
client.post("/api/pipelines", json=p)
resp = client.post("/api/pipelines", json=p)
assert resp.status_code == 409
class TestDeletePipeline:
def test_delete(self, client):
p = make_pipeline()
client.post("/api/pipelines", json=p)
resp = client.delete("/api/pipelines/test-pipeline-id")
assert resp.status_code == 200
assert resp.json()["deleted"] == "test-pipeline-id"
def test_delete_not_found(self, client):
resp = client.delete("/api/pipelines/nonexistent")
assert resp.status_code == 404
class TestExecutePipeline:
def test_not_found(self, client):
resp = client.post("/api/pipelines/nonexistent/execute")
assert resp.status_code == 404
def test_execute(self, client, monkeypatch):
"""Test pipeline execution by patching opencode with echo."""
import asyncio
class FakeStream:
async def readline(self):
return b""
class FakeProcess:
returncode = 0
async def wait(self):
return 0
async def fake_create_subprocess_exec(*args, **kwargs):
proc = FakeProcess()
proc.stdout = FakeStream()
proc.stderr = FakeStream()
return proc
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
p = make_pipeline()
client.post("/api/pipelines", json=p)
resp = client.post("/api/pipelines/test-pipeline-id/execute")
assert resp.status_code == 200
data = resp.json()
assert "run_id" in data
assert "log_file" in data
def test_log_file_created(self, client, monkeypatch):
"""Test that log file is created after execution."""
import asyncio
class FakeStream:
async def readline(self):
return b""
class FakeProcess:
returncode = 0
async def wait(self):
return 0
async def fake_create_subprocess_exec(*args, **kwargs):
proc = FakeProcess()
proc.stdout = FakeStream()
proc.stderr = FakeStream()
return proc
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
p = make_pipeline()
client.post("/api/pipelines", json=p)
resp = client.post("/api/pipelines/test-pipeline-id/execute")
data = resp.json()
log_path = Path(data["log_file"])
assert log_path.exists()
content = log_path.read_text()
assert "Starting pipeline" in content
assert "Pipeline finished" in content
def test_error_step_stops_pipeline(self, client, monkeypatch):
"""Test that pipeline stops on first error."""
import asyncio
class FakeStream:
async def readline(self):
return b""
class FakeProcess:
returncode = 1
async def wait(self):
return 1
async def fake_create_subprocess_exec(*args, **kwargs):
proc = FakeProcess()
proc.stdout = FakeStream()
proc.stderr = FakeStream()
return proc
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
p = make_pipeline(prompt_count=3)
client.post("/api/pipelines", json=p)
resp = client.post("/api/pipelines/test-pipeline-id/execute")
data = resp.json()
log_path = Path(data["log_file"])
content = log_path.read_text()
assert "Pipeline failed at step 1" in content
assert "Step 2" not in content