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