feat: initial commit - pipeline runner with real-time streaming
This commit is contained in:
+55
@@ -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/
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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/` - Логи выполнений
|
||||||
@@ -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}"}
|
||||||
Executable
+12
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import uvicorn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.34.0
|
||||||
|
websockets>=14.0
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest>=8.0
|
||||||
|
httpx2>=0.28.0
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user