rewrite all to PIP
This commit is contained in:
+8
-55
@@ -1,55 +1,8 @@
|
||||
# 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/
|
||||
*.user
|
||||
/build*
|
||||
/*/build*
|
||||
/bin
|
||||
/release
|
||||
*.orig
|
||||
/.*
|
||||
*test-results.xml
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
# 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/` — C++ application built with PIP (Platform Independent Primitives).
|
||||
- `pipeline-runner/src/main.cpp` — entry point, config loading, server start.
|
||||
- `pipeline-runner/src/server.h/.cpp` — PIHTTPServer routes, all API endpoints.
|
||||
- `pipeline-runner/src/pipeline.h/.cpp` — Pipeline/Prompt structs, JSON file I/O, log functions.
|
||||
- `pipeline-runner/src/runner.h/.cpp` — async pipeline execution (PIThread + PIProcess per step).
|
||||
- `pipeline-runner/src/messageutils.h/.cpp` — HTTP response helpers (JSON, error, success).
|
||||
- `pipeline-runner/pipeline-runner.conf` — JSON config file (port, directories).
|
||||
- `pipeline-runner/tests/` — gtest tests for pipeline storage and runner.
|
||||
- `pipeline-runner/CMakeLists.txt` — CMake build config with PIP + gtest (FetchContent).
|
||||
- `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.
|
||||
- Build: `cd pipeline-runner/build && cmake .. && make`
|
||||
- Run: `cd pipeline-runner/build && ./pipeline-runner ../pipeline-runner.conf`
|
||||
- Run tests: `cd pipeline-runner/build && ./test-pipeline-runner`
|
||||
- Clean build: `rm -rf pipeline-runner/build && mkdir pipeline-runner/build && cd pipeline-runner/build && cmake .. && make`
|
||||
|
||||
## API endpoints
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/pipelines` | List all pipelines |
|
||||
| POST | `/api/pipelines` | Create pipeline (JSON body) |
|
||||
| GET | `/api/pipelines/{id}` | Get single pipeline |
|
||||
| DELETE | `/api/pipelines/{id}` | Delete pipeline |
|
||||
| POST | `/api/runs` | Start execution (`{"pipeline_id": "..."}`) |
|
||||
| GET | `/api/runs/{run_id}/status` | Poll run status + step results |
|
||||
| GET | `/api/runs/{run_id}/log` | Get log file contents |
|
||||
|
||||
## Key behaviors
|
||||
- Pipeline execution runs `opencode run <prompt> --title <title>` per step via `asyncio.create_subprocess_exec`.
|
||||
- Pipeline execution runs `opencode run <text> --title <title>` per step via `PIProcess`.
|
||||
- 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.
|
||||
|
||||
- Each run executes in a separate `PIThread`.
|
||||
- CORS headers are added to all responses (`Access-Control-Allow-Origin: *`).
|
||||
- Config is loaded from `pipeline-runner.conf` (JSON format).
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
---
|
||||
Language: Cpp
|
||||
AccessModifierOffset: -4
|
||||
AlignAfterOpenBracket: Align
|
||||
AlignArrayOfStructures: Left
|
||||
AlignConsecutiveAssignments:
|
||||
Enabled: true
|
||||
AcrossEmptyLines: true
|
||||
AcrossComments: true
|
||||
AlignCompound: false
|
||||
PadOperators: true
|
||||
AlignConsecutiveBitFields:
|
||||
Enabled: true
|
||||
AcrossEmptyLines: false
|
||||
AcrossComments: true
|
||||
AlignCompound: false
|
||||
PadOperators: true
|
||||
AlignConsecutiveDeclarations:
|
||||
Enabled: false
|
||||
AcrossEmptyLines: false
|
||||
AcrossComments: false
|
||||
AlignCompound: false
|
||||
PadOperators: false
|
||||
AlignConsecutiveMacros:
|
||||
Enabled: true
|
||||
AcrossEmptyLines: true
|
||||
AcrossComments: true
|
||||
AlignCompound: false
|
||||
PadOperators: true
|
||||
AlignEscapedNewlines: Left
|
||||
AlignOperands: Align
|
||||
AlignTrailingComments: true
|
||||
AllowAllArgumentsOnNextLine: false
|
||||
AllowAllParametersOfDeclarationOnNextLine: false
|
||||
AllowShortEnumsOnASingleLine: false
|
||||
AllowShortBlocksOnASingleLine: Empty
|
||||
AllowShortCaseLabelsOnASingleLine: true
|
||||
AllowShortFunctionsOnASingleLine: Inline
|
||||
AllowShortLambdasOnASingleLine: All
|
||||
AllowShortIfStatementsOnASingleLine: WithoutElse
|
||||
AllowShortLoopsOnASingleLine: false
|
||||
AlwaysBreakAfterDefinitionReturnType: None
|
||||
AlwaysBreakAfterReturnType: None
|
||||
AlwaysBreakBeforeMultilineStrings: false
|
||||
AlwaysBreakTemplateDeclarations: Yes
|
||||
AttributeMacros:
|
||||
- __capability
|
||||
BinPackArguments: false
|
||||
BinPackParameters: false
|
||||
BraceWrapping:
|
||||
AfterCaseLabel: false
|
||||
AfterClass: false
|
||||
AfterControlStatement: Never
|
||||
AfterEnum: false
|
||||
AfterFunction: false
|
||||
AfterNamespace: false
|
||||
AfterObjCDeclaration: false
|
||||
AfterStruct: false
|
||||
AfterUnion: false
|
||||
AfterExternBlock: false
|
||||
BeforeCatch: false
|
||||
BeforeElse: false
|
||||
BeforeLambdaBody: false
|
||||
BeforeWhile: false
|
||||
IndentBraces: false
|
||||
SplitEmptyFunction: false
|
||||
SplitEmptyRecord: false
|
||||
SplitEmptyNamespace: true
|
||||
BreakBeforeBinaryOperators: None
|
||||
BreakBeforeConceptDeclarations: Always
|
||||
BreakBeforeBraces: Attach
|
||||
BreakInheritanceList: BeforeComma
|
||||
BreakBeforeTernaryOperators: true
|
||||
BreakConstructorInitializers: BeforeComma
|
||||
BreakAfterJavaFieldAnnotations: false
|
||||
BreakStringLiterals: true
|
||||
ColumnLimit: 140
|
||||
CommentPragmas: '^ IWYU pragma:'
|
||||
QualifierAlignment: Leave
|
||||
CompactNamespaces: false
|
||||
ConstructorInitializerIndentWidth: 4
|
||||
ContinuationIndentWidth: 4
|
||||
Cpp11BracedListStyle: true
|
||||
DeriveLineEnding: false
|
||||
DerivePointerAlignment: false
|
||||
DisableFormat: false
|
||||
EmptyLineAfterAccessModifier: Never
|
||||
EmptyLineBeforeAccessModifier: Always
|
||||
ExperimentalAutoDetectBinPacking: false
|
||||
PackConstructorInitializers: CurrentLine
|
||||
BasedOnStyle: ''
|
||||
ConstructorInitializerAllOnOneLineOrOnePerLine: true
|
||||
AllowAllConstructorInitializersOnNextLine: true
|
||||
FixNamespaceComments: true
|
||||
ForEachMacros:
|
||||
- foreach
|
||||
- Q_FOREACH
|
||||
- BOOST_FOREACH
|
||||
- piForeach
|
||||
- piForeachC
|
||||
- piForeachR
|
||||
- piForeachRC
|
||||
- piForeachCR
|
||||
IfMacros:
|
||||
- KJ_IF_MAYBE
|
||||
IncludeBlocks: Regroup
|
||||
IncludeCategories:
|
||||
- Regex: '^"(llvm|llvm-c|clang|clang-c)/'
|
||||
Priority: 2
|
||||
SortPriority: 0
|
||||
CaseSensitive: false
|
||||
- Regex: '^(<|"(gtest|gmock|isl|json)/)'
|
||||
Priority: 3
|
||||
SortPriority: 0
|
||||
CaseSensitive: false
|
||||
- Regex: '.*'
|
||||
Priority: 1
|
||||
SortPriority: 0
|
||||
CaseSensitive: false
|
||||
IncludeIsMainRegex: '(Test)?$'
|
||||
IncludeIsMainSourceRegex: ''
|
||||
IndentAccessModifiers: false
|
||||
IndentCaseLabels: false
|
||||
IndentCaseBlocks: false
|
||||
IndentGotoLabels: false
|
||||
IndentPPDirectives: AfterHash
|
||||
IndentExternBlock: NoIndent
|
||||
IndentRequiresClause: true
|
||||
IndentWidth: 4
|
||||
IndentWrappedFunctionNames: false
|
||||
InsertBraces: false
|
||||
InsertTrailingCommas: Wrapped
|
||||
JavaScriptQuotes: Leave
|
||||
JavaScriptWrapImports: true
|
||||
KeepEmptyLinesAtTheStartOfBlocks: false
|
||||
LambdaBodyIndentation: Signature
|
||||
MacroBlockBegin: "PRIVATE_DEFINITION_START|STATIC_INITIALIZER_BEGIN"
|
||||
MacroBlockEnd: "PRIVATE_DEFINITION_END|STATIC_INITIALIZER_END"
|
||||
MaxEmptyLinesToKeep: 2
|
||||
NamespaceIndentation: None
|
||||
ObjCBinPackProtocolList: Auto
|
||||
ObjCBlockIndentWidth: 2
|
||||
ObjCBreakBeforeNestedBlockParam: true
|
||||
ObjCSpaceAfterProperty: false
|
||||
ObjCSpaceBeforeProtocolList: true
|
||||
PenaltyBreakAssignment: 2
|
||||
PenaltyBreakBeforeFirstCallParameter: 19
|
||||
PenaltyBreakComment: 300
|
||||
PenaltyBreakFirstLessLess: 120
|
||||
PenaltyBreakOpenParenthesis: 0
|
||||
PenaltyBreakString: 1000
|
||||
PenaltyBreakTemplateDeclaration: 10
|
||||
PenaltyExcessCharacter: 1000000
|
||||
PenaltyReturnTypeOnItsOwnLine: 60
|
||||
PenaltyIndentedWhitespace: 0
|
||||
PointerAlignment: Middle
|
||||
PPIndentWidth: 2
|
||||
ReferenceAlignment: Middle
|
||||
ReflowComments: true
|
||||
RemoveBracesLLVM: false
|
||||
RequiresClausePosition: OwnLine
|
||||
SeparateDefinitionBlocks: Leave
|
||||
ShortNamespaceLines: 1
|
||||
SortIncludes: CaseSensitive
|
||||
SortJavaStaticImport: Before
|
||||
SortUsingDeclarations: true
|
||||
SpaceAfterCStyleCast: false
|
||||
SpaceAfterLogicalNot: false
|
||||
SpaceAfterTemplateKeyword: false
|
||||
SpaceBeforeAssignmentOperators: true
|
||||
SpaceBeforeCaseColon: false
|
||||
SpaceBeforeCpp11BracedList: false
|
||||
SpaceBeforeCtorInitializerColon: false
|
||||
SpaceBeforeInheritanceColon: false
|
||||
SpaceBeforeParens: ControlStatementsExceptControlMacros
|
||||
SpaceBeforeParensOptions:
|
||||
AfterControlStatements: true
|
||||
AfterForeachMacros: false
|
||||
AfterFunctionDefinitionName: false
|
||||
AfterFunctionDeclarationName: false
|
||||
AfterIfMacros: false
|
||||
AfterOverloadedOperator: false
|
||||
AfterRequiresInClause: false
|
||||
AfterRequiresInExpression: false
|
||||
BeforeNonEmptyParentheses: false
|
||||
SpaceAroundPointerQualifiers: Both
|
||||
SpaceBeforeRangeBasedForLoopColon: false
|
||||
SpaceInEmptyBlock: false
|
||||
SpaceInEmptyParentheses: false
|
||||
SpacesBeforeTrailingComments: 1
|
||||
SpacesInAngles: Never
|
||||
SpacesInConditionalStatement: false
|
||||
SpacesInContainerLiterals: false
|
||||
SpacesInCStyleCastParentheses: false
|
||||
SpacesInLineCommentPrefix:
|
||||
Minimum: 1
|
||||
Maximum: -1
|
||||
SpacesInParentheses: false
|
||||
SpacesInSquareBrackets: false
|
||||
SpaceBeforeSquareBrackets: false
|
||||
BitFieldColonSpacing: After
|
||||
Standard: c++11
|
||||
StatementAttributeLikeMacros:
|
||||
- Q_EMIT
|
||||
- PIMETA
|
||||
StatementMacros:
|
||||
- Q_UNUSED
|
||||
- QT_REQUIRE_VERSION
|
||||
- PRIVATE_DECLARATION
|
||||
- NO_COPY_CLASS
|
||||
- FOREVER_WAIT
|
||||
- WAIT_FOREVER
|
||||
TabWidth: 4
|
||||
UseCRLF: false
|
||||
UseTab: AlignWithSpaces
|
||||
WhitespaceSensitiveMacros:
|
||||
- STRINGIZE
|
||||
- PP_STRINGIZE
|
||||
- BOOST_PP_STRINGIZE
|
||||
- NS_SWIFT_NAME
|
||||
- CF_SWIFT_NAME
|
||||
- PIMETA
|
||||
...
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
project(PipelineRunner)
|
||||
set(CMAKE_CXX_STANDARD 11)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
# PIP
|
||||
find_package(PIP REQUIRED)
|
||||
|
||||
# Google Test
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
googletest
|
||||
GIT_REPOSITORY https://git.shstk.ru/mirrors/googletest.git
|
||||
GIT_TAG v1.14.0
|
||||
)
|
||||
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(googletest)
|
||||
|
||||
# Source files
|
||||
file(GLOB ALL_SRCS "src/*.cpp")
|
||||
file(GLOB HDRS "src/*.h")
|
||||
list(FILTER ALL_SRCS EXCLUDE REGEX ".*main\\.cpp$")
|
||||
set(SRCS ${ALL_SRCS})
|
||||
|
||||
# Main executable
|
||||
add_executable(pipeline-runner src/main.cpp ${SRCS} ${HDRS})
|
||||
target_include_directories(pipeline-runner PRIVATE src)
|
||||
target_link_libraries(pipeline-runner PIP PIP::HTTPServer PIP::Crypt PIP::Console)
|
||||
|
||||
set_target_properties(pipeline-runner PROPERTIES
|
||||
INSTALL_RPATH "\$ORIGIN;\$ORIGIN/lib"
|
||||
BUILD_RPATH "\$ORIGIN;\$ORIGIN/lib"
|
||||
)
|
||||
|
||||
# Tests
|
||||
add_executable(test-pipeline-runner tests/test_pipeline.cpp tests/test_runner.cpp ${SRCS})
|
||||
target_include_directories(test-pipeline-runner PRIVATE src tests)
|
||||
target_link_libraries(test-pipeline-runner PIP PIP::HTTPServer PIP::Crypt PIP::Console GTest::gtest_main)
|
||||
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(test-pipeline-runner)
|
||||
|
||||
# Install
|
||||
install(TARGETS pipeline-runner DESTINATION bin)
|
||||
install(FILES pipeline-runner.conf DESTINATION bin)
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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/` - Логи выполнений
|
||||
@@ -1,226 +0,0 @@
|
||||
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}"}
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,4 +0,0 @@
|
||||
import uvicorn
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"port": 8000,
|
||||
"pipelines_dir": "./storage/pipelines/",
|
||||
"logs_dir": "./storage/logs/"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.34.0
|
||||
websockets>=14.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0
|
||||
httpx2>=0.28.0
|
||||
@@ -0,0 +1,69 @@
|
||||
#include "server.h"
|
||||
|
||||
#include <picout.h>
|
||||
#include <pifile.h>
|
||||
#include <pijson.h>
|
||||
#include <pikbdlistener.h>
|
||||
#include <pisignals.h>
|
||||
|
||||
static PIString getConfigValue(const PIJSON & config, const PIString & key, const PIString & def = PIString()) {
|
||||
if (config.contains(key)) {
|
||||
return config[key].toString();
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
static int getConfigInt(const PIJSON & config, const PIString & key, int def) {
|
||||
if (config.contains(key)) {
|
||||
return config[key].toInt();
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
int main(int argc, char * argv[]) {
|
||||
// Load config file
|
||||
PIString configPath = "./pipeline-runner.conf";
|
||||
PIJSON config;
|
||||
|
||||
if (argc > 1) {
|
||||
configPath = PIString(argv[1]);
|
||||
}
|
||||
|
||||
if (PIFile::isExists(configPath)) {
|
||||
PIByteArray data = PIFile::readAll(configPath);
|
||||
config = PIJSON::fromJSON(PIString::fromUTF8(data));
|
||||
piCout << "Loaded config from: " << configPath;
|
||||
} else {
|
||||
piCout << "Config file not found: " << configPath << ", using defaults";
|
||||
}
|
||||
|
||||
// Parse config
|
||||
int port = getConfigInt(config, "port", 8000);
|
||||
PIString pipelinesDir = getConfigValue(config, "pipelines_dir", "./storage/pipelines/");
|
||||
PIString logsDir = getConfigValue(config, "logs_dir", "./storage/logs/");
|
||||
|
||||
piCout << "Port: " << port;
|
||||
piCout << "Pipelines dir: " << pipelinesDir;
|
||||
piCout << "Logs dir: " << logsDir;
|
||||
|
||||
// Handle signals
|
||||
PISignals::setSlot([](PISignals::Signal s) {
|
||||
piCout << "Interrupt received, shutting down...";
|
||||
PIKbdListener::exiting = true;
|
||||
PISignals::releaseSignals(s);
|
||||
});
|
||||
PISignals::grabSignals(PISignals::Interrupt);
|
||||
|
||||
// Start server
|
||||
Server server(static_cast<ushort>(port), pipelinesDir, logsDir);
|
||||
server.start();
|
||||
|
||||
// Wait for exit
|
||||
PIKbdListener listener;
|
||||
listener.start();
|
||||
WAIT_FOR_EXIT
|
||||
listener.stopAndWait();
|
||||
|
||||
piCout << "Server stopped";
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#include "messageutils.h"
|
||||
|
||||
#include <pihttpserver.h>
|
||||
|
||||
namespace MessageUtils {
|
||||
|
||||
PIHTTP::MessageMutable jsonReply(PIHTTP::Code code, const PIJSON & json) {
|
||||
PIHTTP::MessageMutable msg;
|
||||
msg.setCode(code);
|
||||
msg.setBody(json.toJSON(PIJSON::Compact).toUTF8());
|
||||
msg.addHeader("Content-Type", "application/json; charset=utf-8");
|
||||
return msg;
|
||||
}
|
||||
|
||||
PIHTTP::MessageMutable errorReply(PIHTTP::Code code, const PIString & message) {
|
||||
PIJSON j = PIJSON::newObject();
|
||||
j["error"] = message;
|
||||
return jsonReply(code, j);
|
||||
}
|
||||
|
||||
PIHTTP::MessageMutable successReply(const PIJSON & json) {
|
||||
return jsonReply(PIHTTP::Code::Ok, json);
|
||||
}
|
||||
|
||||
PIHTTP::MessageMutable noContent() {
|
||||
PIHTTP::MessageMutable msg;
|
||||
msg.setCode(PIHTTP::Code::NoContent);
|
||||
return msg;
|
||||
}
|
||||
|
||||
} // namespace MessageUtils
|
||||
@@ -0,0 +1,17 @@
|
||||
#ifndef MESSAGEUTILS_H
|
||||
#define MESSAGEUTILS_H
|
||||
|
||||
#include <pihttptypes.h>
|
||||
#include <pijson.h>
|
||||
#include <pistring.h>
|
||||
|
||||
namespace MessageUtils {
|
||||
|
||||
PIHTTP::MessageMutable jsonReply(PIHTTP::Code code, const PIJSON & json);
|
||||
PIHTTP::MessageMutable errorReply(PIHTTP::Code code, const PIString & message);
|
||||
PIHTTP::MessageMutable successReply(const PIJSON & json);
|
||||
PIHTTP::MessageMutable noContent();
|
||||
|
||||
} // namespace MessageUtils
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,138 @@
|
||||
#include "pipeline.h"
|
||||
|
||||
#include <picrypt.h>
|
||||
#include <pidatetime.h>
|
||||
#include <pidir.h>
|
||||
#include <pifile.h>
|
||||
#include <piiodevice.h>
|
||||
|
||||
static PIJSON promptToJSON(const Prompt & p) {
|
||||
PIJSON j = PIJSON::newObject();
|
||||
j["id"] = p.id;
|
||||
j["text"] = p.text;
|
||||
j["title"] = p.title;
|
||||
j["order"] = p.order;
|
||||
return j;
|
||||
}
|
||||
|
||||
static Prompt promptFromJSON(const PIJSON & j) {
|
||||
Prompt p;
|
||||
p.id = j["id"].toString();
|
||||
p.text = j["text"].toString();
|
||||
p.title = j["title"].toString();
|
||||
p.order = j["order"].toInt();
|
||||
return p;
|
||||
}
|
||||
|
||||
static PIJSON pipelineToJSON(const Pipeline & pl) {
|
||||
PIJSON j = PIJSON::newObject();
|
||||
j["id"] = pl.id;
|
||||
j["name"] = pl.name;
|
||||
j["working_dir"] = pl.working_dir;
|
||||
j["created_at"] = pl.created_at;
|
||||
j["updated_at"] = pl.updated_at;
|
||||
|
||||
PIJSON arr = PIJSON::newArray();
|
||||
for (const auto & p: pl.prompts) {
|
||||
arr << promptToJSON(p);
|
||||
}
|
||||
j["prompts"] = arr;
|
||||
return j;
|
||||
}
|
||||
|
||||
static Pipeline pipelineFromJSON(const PIJSON & j) {
|
||||
Pipeline pl;
|
||||
pl.id = j["id"].toString();
|
||||
pl.name = j["name"].toString();
|
||||
pl.working_dir = j["working_dir"].toString();
|
||||
pl.created_at = j["created_at"].toString();
|
||||
pl.updated_at = j["updated_at"].toString();
|
||||
|
||||
PIJSON promptsJ = j["prompts"];
|
||||
if (promptsJ.isArray()) {
|
||||
const auto & promptsArr = promptsJ.array();
|
||||
for (int i = 0; i < promptsArr.size(); ++i) {
|
||||
if (promptsArr[i].isObject()) {
|
||||
pl.prompts << promptFromJSON(promptsArr[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return pl;
|
||||
}
|
||||
|
||||
PIVector<Pipeline> loadPipelines(const PIString & pipelines_dir) {
|
||||
PIVector<Pipeline> result;
|
||||
PIDir dir(pipelines_dir);
|
||||
if (!dir.isExists()) return result;
|
||||
|
||||
const auto entries = dir.entries();
|
||||
for (const auto & entry: entries) {
|
||||
if (!entry.isFile() || entry.extension() != "json") continue;
|
||||
PIByteArray data = PIFile::readAll(entry.path);
|
||||
PIJSON j = PIJSON::fromJSON(PIString::fromUTF8(data));
|
||||
if (j.isObject()) {
|
||||
result << pipelineFromJSON(j);
|
||||
}
|
||||
}
|
||||
// Sort by updated_at descending
|
||||
result.sort([](const Pipeline & a, const Pipeline & b) { return a.updated_at > b.updated_at; });
|
||||
return result;
|
||||
}
|
||||
|
||||
bool savePipeline(const PIString & pipelines_dir, Pipeline & pipeline) {
|
||||
pipeline.updated_at = nowISO();
|
||||
PIJSON j = pipelineToJSON(pipeline);
|
||||
PIString path = pipelines_dir + pipeline.id + ".json";
|
||||
return PIFile::writeAll(path, j.toJSON(PIJSON::Tree).toUTF8());
|
||||
}
|
||||
|
||||
bool deletePipeline(const PIString & pipelines_dir, const PIString & pipeline_id) {
|
||||
PIString path = pipelines_dir + pipeline_id + ".json";
|
||||
return PIFile::remove(path);
|
||||
}
|
||||
|
||||
Pipeline findPipeline(const PIString & pipelines_dir, const PIString & pipeline_id) {
|
||||
PIString path = pipelines_dir + pipeline_id + ".json";
|
||||
if (!PIFile::isExists(path)) return Pipeline();
|
||||
PIByteArray data = PIFile::readAll(path);
|
||||
PIJSON j = PIJSON::fromJSON(PIString::fromUTF8(data));
|
||||
if (j.isObject()) {
|
||||
return pipelineFromJSON(j);
|
||||
}
|
||||
return Pipeline();
|
||||
}
|
||||
|
||||
void appendLog(const PIString & logs_dir, const PIString & run_id, const PIString & line) {
|
||||
PIString path = logs_dir + run_id + ".log";
|
||||
PIFile f(path, PIIODevice::ReadWrite);
|
||||
if (f.isOpened()) {
|
||||
f.seekToEnd();
|
||||
PIString ts = nowISO();
|
||||
f.write((ts + " " + line + "\n").toUTF8());
|
||||
f.close();
|
||||
}
|
||||
}
|
||||
|
||||
PIString readLog(const PIString & logs_dir, const PIString & run_id) {
|
||||
PIString path = logs_dir + run_id + ".log";
|
||||
if (!PIFile::isExists(path)) return PIString();
|
||||
PIByteArray data = PIFile::readAll(path);
|
||||
return PIString::fromUTF8(data);
|
||||
}
|
||||
|
||||
PIString generateUUID() {
|
||||
PIByteArray bytes = PICrypt::generateRandomBuff(16);
|
||||
PIString hex = bytes.toHex();
|
||||
// Format as UUID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
return hex.left(8) + "-" + hex.mid(8, 4) + "-" + hex.mid(12, 4) + "-" + hex.mid(16, 4) + "-" + hex.mid(20, 12);
|
||||
}
|
||||
|
||||
static PIString pad2(int v) {
|
||||
return v < 10 ? "0" + PIString::fromNumber(v) : PIString::fromNumber(v);
|
||||
}
|
||||
|
||||
PIString nowISO() {
|
||||
PIDateTime dt = PIDateTime::current();
|
||||
return PIString::fromNumber(dt.year) + "-" + pad2(dt.month) + "-" + pad2(dt.day) + "T" + pad2(dt.hours) + ":" + pad2(dt.minutes) + ":" +
|
||||
pad2(dt.seconds) + "Z";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#ifndef PIPELINE_H
|
||||
#define PIPELINE_H
|
||||
|
||||
#include <pijson.h>
|
||||
#include <pimap.h>
|
||||
#include <pistring.h>
|
||||
#include <pivector.h>
|
||||
|
||||
struct Prompt {
|
||||
PIString id;
|
||||
PIString text;
|
||||
PIString title;
|
||||
int order = 0;
|
||||
};
|
||||
|
||||
struct Pipeline {
|
||||
PIString id;
|
||||
PIString name;
|
||||
PIVector<Prompt> prompts;
|
||||
PIString working_dir;
|
||||
PIString created_at;
|
||||
PIString updated_at;
|
||||
};
|
||||
|
||||
// Storage functions
|
||||
PIVector<Pipeline> loadPipelines(const PIString & pipelines_dir);
|
||||
bool savePipeline(const PIString & pipelines_dir, Pipeline & pipeline);
|
||||
bool deletePipeline(const PIString & pipelines_dir, const PIString & pipeline_id);
|
||||
Pipeline findPipeline(const PIString & pipelines_dir, const PIString & pipeline_id);
|
||||
|
||||
// Log functions
|
||||
void appendLog(const PIString & logs_dir, const PIString & run_id, const PIString & line);
|
||||
PIString readLog(const PIString & logs_dir, const PIString & run_id);
|
||||
|
||||
// Utility
|
||||
PIString generateUUID();
|
||||
PIString nowISO();
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,149 @@
|
||||
#include "runner.h"
|
||||
|
||||
#include "pipeline.h"
|
||||
|
||||
#include <piprocess.h>
|
||||
#include <pisystemtime.h>
|
||||
|
||||
PipelineRunner::PipelineRunner(const PIString & pipelines_dir, const PIString & logs_dir)
|
||||
: pipelines_dir_(pipelines_dir)
|
||||
, logs_dir_(logs_dir) {}
|
||||
|
||||
PipelineRunner::~PipelineRunner() {}
|
||||
|
||||
PIString PipelineRunner::startRun(const PIString & pipeline_id) {
|
||||
Pipeline pipeline = findPipeline(pipelines_dir_, pipeline_id);
|
||||
if (pipeline.id.isEmpty()) return PIString();
|
||||
|
||||
PIString run_id = generateUUID();
|
||||
|
||||
RunState state;
|
||||
state.run_id = run_id;
|
||||
state.pipeline_id = pipeline_id;
|
||||
state.status = RunStatus::Running;
|
||||
state.current_step = 0;
|
||||
|
||||
// Initialize step results
|
||||
for (int i = 0; i < pipeline.prompts.size(); ++i) {
|
||||
StepResult sr;
|
||||
sr.step_index = i;
|
||||
sr.title = pipeline.prompts[i].title;
|
||||
sr.status = RunStatus::Pending;
|
||||
state.steps << sr;
|
||||
}
|
||||
|
||||
{
|
||||
PIMutexLocker ml(mutex_);
|
||||
runs_.insert(run_id, state);
|
||||
active_runs_.insert(run_id, true);
|
||||
}
|
||||
|
||||
appendLog(run_id, "Starting pipeline: " + pipeline.name + " (run_id: " + run_id + ")");
|
||||
|
||||
// Execute in a separate thread
|
||||
// Use a lambda that captures a copy of the pipeline and run_id
|
||||
PIString rid = run_id;
|
||||
PIString pdir = pipelines_dir_;
|
||||
PIString ldir = logs_dir_;
|
||||
PIThread * thread = new PIThread([this, rid, pipeline, pdir, ldir]() { executePipeline(rid, pipeline); });
|
||||
thread->start();
|
||||
|
||||
return run_id;
|
||||
}
|
||||
|
||||
RunState PipelineRunner::getRunState(const PIString & run_id) {
|
||||
PIMutexLocker ml(mutex_);
|
||||
if (runs_.contains(run_id)) {
|
||||
return runs_.value(run_id);
|
||||
}
|
||||
return RunState();
|
||||
}
|
||||
|
||||
bool PipelineRunner::isRunActive(const PIString & run_id) {
|
||||
PIMutexLocker ml(mutex_);
|
||||
return active_runs_.contains(run_id);
|
||||
}
|
||||
|
||||
void PipelineRunner::executePipeline(const PIString & run_id, const Pipeline & pipeline) {
|
||||
int total = pipeline.prompts.size();
|
||||
|
||||
for (int i = 0; i < total; ++i) {
|
||||
const Prompt & prompt = pipeline.prompts[i];
|
||||
appendLog(run_id, "[Step " + PIString::fromNumber(i + 1) + "/" + PIString::fromNumber(total) + "] Starting: " + prompt.title);
|
||||
appendLog(run_id, "[Step " + PIString::fromNumber(i + 1) + "/" + PIString::fromNumber(total) + "] Prompt: " + prompt.text);
|
||||
|
||||
// Update step status to running
|
||||
{
|
||||
PIMutexLocker ml(mutex_);
|
||||
if (runs_.contains(run_id)) {
|
||||
runs_[run_id].current_step = i;
|
||||
runs_[run_id].steps[i].status = RunStatus::Running;
|
||||
}
|
||||
}
|
||||
|
||||
// Build command: opencode run <text> --title <title>
|
||||
PIProcess proc;
|
||||
proc.enableReadStdOut(true);
|
||||
proc.enableReadStdErr(true);
|
||||
|
||||
if (!pipeline.working_dir.isEmpty()) {
|
||||
proc.setWorkingDirectory(pipeline.working_dir);
|
||||
}
|
||||
|
||||
PIStringList args;
|
||||
args << "run" << prompt.text << "--title" << prompt.title;
|
||||
proc.exec("opencode", args);
|
||||
|
||||
// Wait for process to finish (max 300 seconds per step)
|
||||
proc.waitForFinish(PISystemTime::fromSeconds(300));
|
||||
|
||||
PIByteArray stdoutData = proc.readOutput();
|
||||
PIByteArray stderrData = proc.readError();
|
||||
int rc = proc.exitCode();
|
||||
|
||||
PIString output = PIString::fromUTF8(stdoutData);
|
||||
PIString error = PIString::fromUTF8(stderrData);
|
||||
|
||||
RunStatus stepStatus = (rc == 0) ? RunStatus::Completed : RunStatus::Error;
|
||||
appendLog(run_id,
|
||||
"[Step " + PIString::fromNumber(i + 1) + "/" + PIString::fromNumber(total) + "] " +
|
||||
runStatusToString(stepStatus).toUpperCase() + " (returncode: " + PIString::fromNumber(rc) + ")");
|
||||
|
||||
// Update step result
|
||||
{
|
||||
PIMutexLocker ml(mutex_);
|
||||
if (runs_.contains(run_id)) {
|
||||
runs_[run_id].steps[i].status = stepStatus;
|
||||
runs_[run_id].steps[i].returncode = rc;
|
||||
runs_[run_id].steps[i].output = output;
|
||||
runs_[run_id].steps[i].error = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (rc != 0) {
|
||||
appendLog(run_id, "Pipeline failed at step " + PIString::fromNumber(i + 1));
|
||||
{
|
||||
PIMutexLocker ml(mutex_);
|
||||
if (runs_.contains(run_id)) {
|
||||
runs_[run_id].status = RunStatus::Error;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as completed if all steps passed
|
||||
{
|
||||
PIMutexLocker ml(mutex_);
|
||||
if (runs_.contains(run_id) && runs_[run_id].status == RunStatus::Running) {
|
||||
runs_[run_id].status = RunStatus::Completed;
|
||||
}
|
||||
active_runs_.remove(run_id);
|
||||
}
|
||||
|
||||
appendLog(run_id, "Pipeline finished");
|
||||
}
|
||||
|
||||
void PipelineRunner::appendLog(const PIString & run_id, const PIString & line) {
|
||||
::appendLog(logs_dir_, run_id, line);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#ifndef RUNNER_H
|
||||
#define RUNNER_H
|
||||
|
||||
#include "pipeline.h"
|
||||
|
||||
#include <pimap.h>
|
||||
#include <pimutex.h>
|
||||
#include <piobject.h>
|
||||
#include <pistring.h>
|
||||
#include <pivector.h>
|
||||
|
||||
enum class RunStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Error
|
||||
};
|
||||
|
||||
static PIString runStatusToString(RunStatus s) {
|
||||
switch (s) {
|
||||
case RunStatus::Pending: return "pending";
|
||||
case RunStatus::Running: return "running";
|
||||
case RunStatus::Completed: return "completed";
|
||||
case RunStatus::Error: return "error";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
struct StepResult {
|
||||
int step_index = 0;
|
||||
PIString title;
|
||||
RunStatus status = RunStatus::Pending;
|
||||
int returncode = 0;
|
||||
PIString output;
|
||||
PIString error;
|
||||
};
|
||||
|
||||
struct RunState {
|
||||
PIString run_id;
|
||||
PIString pipeline_id;
|
||||
RunStatus status = RunStatus::Pending;
|
||||
int current_step = -1;
|
||||
PIVector<StepResult> steps;
|
||||
};
|
||||
|
||||
class PipelineRunner: public PIObject {
|
||||
PIOBJECT(PipelineRunner)
|
||||
|
||||
public:
|
||||
PipelineRunner(const PIString & pipelines_dir, const PIString & logs_dir);
|
||||
~PipelineRunner();
|
||||
|
||||
// Start a pipeline run, returns run_id or empty string on error
|
||||
PIString startRun(const PIString & pipeline_id);
|
||||
|
||||
// Get current state of a run
|
||||
RunState getRunState(const PIString & run_id);
|
||||
|
||||
// Check if run is still active
|
||||
bool isRunActive(const PIString & run_id);
|
||||
|
||||
private:
|
||||
void executePipeline(const PIString & run_id, const Pipeline & pipeline);
|
||||
void appendLog(const PIString & run_id, const PIString & line);
|
||||
|
||||
const PIString pipelines_dir_;
|
||||
const PIString logs_dir_;
|
||||
|
||||
PIMutex mutex_;
|
||||
PIMap<PIString, RunState> runs_;
|
||||
PIMap<PIString, bool> active_runs_;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,261 @@
|
||||
#include "server.h"
|
||||
|
||||
#include "messageutils.h"
|
||||
|
||||
#include <pidir.h>
|
||||
#include <pifile.h>
|
||||
#include <pihttpserver.h>
|
||||
|
||||
Server::Server(ushort port, const PIString & pipelines_dir, const PIString & logs_dir)
|
||||
: port_(port)
|
||||
, pipelines_dir_(pipelines_dir)
|
||||
, logs_dir_(logs_dir)
|
||||
, runner_(pipelines_dir, logs_dir) {
|
||||
httpserver_ = new PIHTTPServer();
|
||||
|
||||
// CORS headers
|
||||
httpserver_->addReplyHeader("Access-Control-Allow-Origin", "*");
|
||||
httpserver_->addReplyHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS");
|
||||
httpserver_->addReplyHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
}
|
||||
|
||||
Server::~Server() {
|
||||
delete httpserver_;
|
||||
}
|
||||
|
||||
bool Server::start() {
|
||||
// Ensure storage directories exist
|
||||
PIDir::make(pipelines_dir_);
|
||||
PIDir::make(logs_dir_);
|
||||
|
||||
// Register routes
|
||||
httpserver_->registerPath("/api/pipelines", PIHTTP::Method::Get, this, &Server::listPipelines);
|
||||
httpserver_->registerPath("/api/pipelines", PIHTTP::Method::Post, this, &Server::createPipeline);
|
||||
httpserver_->registerPath("/api/pipelines/{id}", PIHTTP::Method::Get, this, &Server::getPipeline);
|
||||
httpserver_->registerPath("/api/pipelines/{id}", PIHTTP::Method::Delete, this, &Server::deletePipeline);
|
||||
httpserver_->registerPath("/api/runs", PIHTTP::Method::Post, this, &Server::startRun);
|
||||
httpserver_->registerPath("/api/runs/{run_id}/status", PIHTTP::Method::Get, this, &Server::getRunStatus);
|
||||
httpserver_->registerPath("/api/runs/{run_id}/log", PIHTTP::Method::Get, this, &Server::getRunLog);
|
||||
|
||||
// Unhandled request handler
|
||||
httpserver_->registerUnhandled(this, &Server::unhandledRequest);
|
||||
|
||||
bool ok = httpserver_->listenAll(port_);
|
||||
piCout << "Server started on port " << port_ << " (ok=" << ok << ")";
|
||||
return ok;
|
||||
}
|
||||
|
||||
PIHTTP::MessageMutable Server::listPipelines(const PIHTTP::MessageConst & request) {
|
||||
piCout << "GET /api/pipelines";
|
||||
PIVector<Pipeline> pipelines = loadPipelines(pipelines_dir_);
|
||||
|
||||
PIJSON arr = PIJSON::newArray();
|
||||
for (const auto & pl: pipelines) {
|
||||
PIJSON j = PIJSON::newObject();
|
||||
j["id"] = pl.id;
|
||||
j["name"] = pl.name;
|
||||
j["working_dir"] = pl.working_dir;
|
||||
j["created_at"] = pl.created_at;
|
||||
j["updated_at"] = pl.updated_at;
|
||||
|
||||
PIJSON promptsArr = PIJSON::newArray();
|
||||
for (const auto & p: pl.prompts) {
|
||||
PIJSON pj = PIJSON::newObject();
|
||||
pj["id"] = p.id;
|
||||
pj["text"] = p.text;
|
||||
pj["title"] = p.title;
|
||||
pj["order"] = p.order;
|
||||
promptsArr << pj;
|
||||
}
|
||||
j["prompts"] = promptsArr;
|
||||
arr << j;
|
||||
}
|
||||
return MessageUtils::successReply(arr);
|
||||
}
|
||||
|
||||
PIHTTP::MessageMutable Server::createPipeline(const PIHTTP::MessageConst & request) {
|
||||
piCout << "POST /api/pipelines";
|
||||
|
||||
PIString body = PIString::fromUTF8(request.body());
|
||||
PIJSON j = PIJSON::fromJSON(body);
|
||||
if (!j.isObject()) {
|
||||
return MessageUtils::errorReply(PIHTTP::Code::BadRequest, "Invalid JSON");
|
||||
}
|
||||
|
||||
Pipeline pl;
|
||||
pl.id = j["id"].toString();
|
||||
pl.name = j["name"].toString();
|
||||
pl.working_dir = j["working_dir"].toString();
|
||||
pl.created_at = j.contains("created_at") ? j["created_at"].toString() : nowISO();
|
||||
pl.updated_at = pl.created_at;
|
||||
|
||||
if (pl.id.isEmpty() || pl.name.isEmpty()) {
|
||||
return MessageUtils::errorReply(PIHTTP::Code::BadRequest, "id and name are required");
|
||||
}
|
||||
|
||||
// Parse prompts
|
||||
const auto & promptsArr = j["prompts"].array();
|
||||
for (int i = 0; i < promptsArr.size(); ++i) {
|
||||
Prompt p;
|
||||
p.id = promptsArr[i]["id"].toString();
|
||||
p.text = promptsArr[i]["text"].toString();
|
||||
p.title = promptsArr[i]["title"].toString();
|
||||
p.order = promptsArr[i]["order"].toInt();
|
||||
pl.prompts << p;
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
Pipeline existing = findPipeline(pipelines_dir_, pl.id);
|
||||
if (!existing.id.isEmpty()) {
|
||||
return MessageUtils::errorReply(PIHTTP::Code::Conflict, "Pipeline already exists");
|
||||
}
|
||||
|
||||
if (savePipeline(pipelines_dir_, pl)) {
|
||||
PIJSON resp = PIJSON::newObject();
|
||||
resp["id"] = pl.id;
|
||||
resp["name"] = pl.name;
|
||||
resp["working_dir"] = pl.working_dir;
|
||||
resp["created_at"] = pl.created_at;
|
||||
resp["updated_at"] = pl.updated_at;
|
||||
PIJSON pArr = PIJSON::newArray();
|
||||
for (const auto & p: pl.prompts) {
|
||||
PIJSON pj = PIJSON::newObject();
|
||||
pj["id"] = p.id;
|
||||
pj["text"] = p.text;
|
||||
pj["title"] = p.title;
|
||||
pj["order"] = p.order;
|
||||
pArr << pj;
|
||||
}
|
||||
resp["prompts"] = pArr;
|
||||
return MessageUtils::successReply(resp);
|
||||
}
|
||||
|
||||
return MessageUtils::errorReply(PIHTTP::Code::InternalServerError, "Failed to save pipeline");
|
||||
}
|
||||
|
||||
PIHTTP::MessageMutable Server::getPipeline(const PIHTTP::MessageConst & request) {
|
||||
PIString id = request.pathArguments().value("id");
|
||||
piCout << "GET /api/pipelines/" << id;
|
||||
|
||||
Pipeline pl = findPipeline(pipelines_dir_, id);
|
||||
if (pl.id.isEmpty()) {
|
||||
return MessageUtils::errorReply(PIHTTP::Code::NotFound, "Pipeline not found");
|
||||
}
|
||||
|
||||
PIJSON j = PIJSON::newObject();
|
||||
j["id"] = pl.id;
|
||||
j["name"] = pl.name;
|
||||
j["working_dir"] = pl.working_dir;
|
||||
j["created_at"] = pl.created_at;
|
||||
j["updated_at"] = pl.updated_at;
|
||||
|
||||
PIJSON pArr = PIJSON::newArray();
|
||||
for (const auto & p: pl.prompts) {
|
||||
PIJSON pj = PIJSON::newObject();
|
||||
pj["id"] = p.id;
|
||||
pj["text"] = p.text;
|
||||
pj["title"] = p.title;
|
||||
pj["order"] = p.order;
|
||||
pArr << pj;
|
||||
}
|
||||
j["prompts"] = pArr;
|
||||
return MessageUtils::successReply(j);
|
||||
}
|
||||
|
||||
PIHTTP::MessageMutable Server::deletePipeline(const PIHTTP::MessageConst & request) {
|
||||
PIString id = request.pathArguments().value("id");
|
||||
piCout << "DELETE /api/pipelines/" << id;
|
||||
|
||||
Pipeline existing = findPipeline(pipelines_dir_, id);
|
||||
if (existing.id.isEmpty()) {
|
||||
return MessageUtils::errorReply(PIHTTP::Code::NotFound, "Pipeline not found");
|
||||
}
|
||||
|
||||
if (::deletePipeline(pipelines_dir_, id)) {
|
||||
PIJSON j = PIJSON::newObject();
|
||||
j["deleted"] = id;
|
||||
return MessageUtils::successReply(j);
|
||||
}
|
||||
|
||||
return MessageUtils::errorReply(PIHTTP::Code::InternalServerError, "Failed to delete pipeline");
|
||||
}
|
||||
|
||||
PIHTTP::MessageMutable Server::startRun(const PIHTTP::MessageConst & request) {
|
||||
piCout << "POST /api/runs";
|
||||
|
||||
PIString body = PIString::fromUTF8(request.body());
|
||||
PIJSON j = PIJSON::fromJSON(body);
|
||||
if (!j.isObject()) {
|
||||
return MessageUtils::errorReply(PIHTTP::Code::BadRequest, "Invalid JSON");
|
||||
}
|
||||
|
||||
PIString pipeline_id = j["pipeline_id"].toString();
|
||||
if (pipeline_id.isEmpty()) {
|
||||
return MessageUtils::errorReply(PIHTTP::Code::BadRequest, "pipeline_id is required");
|
||||
}
|
||||
|
||||
// Check pipeline exists
|
||||
Pipeline pl = findPipeline(pipelines_dir_, pipeline_id);
|
||||
if (pl.id.isEmpty()) {
|
||||
return MessageUtils::errorReply(PIHTTP::Code::NotFound, "Pipeline not found");
|
||||
}
|
||||
|
||||
PIString run_id = runner_.startRun(pipeline_id);
|
||||
if (run_id.isEmpty()) {
|
||||
return MessageUtils::errorReply(PIHTTP::Code::InternalServerError, "Failed to start run");
|
||||
}
|
||||
|
||||
PIJSON resp = PIJSON::newObject();
|
||||
resp["run_id"] = run_id;
|
||||
resp["pipeline_id"] = pipeline_id;
|
||||
resp["status"] = "running";
|
||||
return MessageUtils::successReply(resp);
|
||||
}
|
||||
|
||||
PIHTTP::MessageMutable Server::getRunStatus(const PIHTTP::MessageConst & request) {
|
||||
PIString run_id = request.pathArguments().value("run_id");
|
||||
piCout << "GET /api/runs/" << run_id << "/status";
|
||||
|
||||
RunState state = runner_.getRunState(run_id);
|
||||
if (state.run_id.isEmpty()) {
|
||||
return MessageUtils::errorReply(PIHTTP::Code::NotFound, "Run not found");
|
||||
}
|
||||
|
||||
PIJSON j = PIJSON::newObject();
|
||||
j["run_id"] = state.run_id;
|
||||
j["pipeline_id"] = state.pipeline_id;
|
||||
j["status"] = runStatusToString(state.status);
|
||||
j["current_step"] = state.current_step;
|
||||
|
||||
PIJSON stepsArr = PIJSON::newArray();
|
||||
for (const auto & step: state.steps) {
|
||||
PIJSON sj = PIJSON::newObject();
|
||||
sj["step_index"] = step.step_index;
|
||||
sj["title"] = step.title;
|
||||
sj["status"] = runStatusToString(step.status);
|
||||
sj["returncode"] = step.returncode;
|
||||
sj["output"] = step.output;
|
||||
sj["error"] = step.error;
|
||||
stepsArr << sj;
|
||||
}
|
||||
j["steps"] = stepsArr;
|
||||
|
||||
return MessageUtils::successReply(j);
|
||||
}
|
||||
|
||||
PIHTTP::MessageMutable Server::getRunLog(const PIHTTP::MessageConst & request) {
|
||||
PIString run_id = request.pathArguments().value("run_id");
|
||||
piCout << "GET /api/runs/" << run_id << "/log";
|
||||
|
||||
PIString log = ::readLog(logs_dir_, run_id);
|
||||
PIHTTP::MessageMutable msg;
|
||||
msg.setCode(PIHTTP::Code::Ok);
|
||||
msg.setBody(log.toUTF8());
|
||||
msg.addHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
return msg;
|
||||
}
|
||||
|
||||
PIHTTP::MessageMutable Server::unhandledRequest(const PIHTTP::MessageConst & request) {
|
||||
piCout << "Unhandled: " << PIHTTP::methodName(request.method()) << " " << request.path();
|
||||
return MessageUtils::errorReply(PIHTTP::Code::NotFound, "Not found");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#ifndef SERVER_H
|
||||
#define SERVER_H
|
||||
|
||||
#include "pipeline.h"
|
||||
#include "runner.h"
|
||||
|
||||
#include <pihttptypes.h>
|
||||
#include <piobject.h>
|
||||
|
||||
class PIHTTPServer;
|
||||
|
||||
class Server: public PIObject {
|
||||
PIOBJECT(Server)
|
||||
|
||||
public:
|
||||
Server(ushort port, const PIString & pipelines_dir, const PIString & logs_dir);
|
||||
~Server();
|
||||
|
||||
bool start();
|
||||
|
||||
private:
|
||||
PIHTTP::MessageMutable listPipelines(const PIHTTP::MessageConst & request);
|
||||
PIHTTP::MessageMutable createPipeline(const PIHTTP::MessageConst & request);
|
||||
PIHTTP::MessageMutable deletePipeline(const PIHTTP::MessageConst & request);
|
||||
PIHTTP::MessageMutable getPipeline(const PIHTTP::MessageConst & request);
|
||||
PIHTTP::MessageMutable startRun(const PIHTTP::MessageConst & request);
|
||||
PIHTTP::MessageMutable getRunStatus(const PIHTTP::MessageConst & request);
|
||||
PIHTTP::MessageMutable getRunLog(const PIHTTP::MessageConst & request);
|
||||
PIHTTP::MessageMutable unhandledRequest(const PIHTTP::MessageConst & request);
|
||||
|
||||
ushort port_;
|
||||
PIString pipelines_dir_;
|
||||
PIString logs_dir_;
|
||||
PIHTTPServer * httpserver_;
|
||||
PipelineRunner runner_;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -1,432 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,189 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,166 @@
|
||||
#include "pipeline.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <pidir.h>
|
||||
#include <pifile.h>
|
||||
|
||||
static const PIString testDir = "/tmp/pipeline-runner-test/";
|
||||
static const PIString testPipelinesDir = testDir + "pipelines/";
|
||||
static const PIString testLogsDir = testDir + "logs/";
|
||||
|
||||
static void setupDirs() {
|
||||
PIDir::make(testDir);
|
||||
PIDir::make(testPipelinesDir);
|
||||
PIDir::make(testLogsDir);
|
||||
}
|
||||
|
||||
static void cleanupDirs() {
|
||||
// Remove all files first using their full path
|
||||
PIDir pdir(testPipelinesDir);
|
||||
for (const auto & e: pdir.entries()) {
|
||||
if (e.isFile()) PIFile::remove(e.path);
|
||||
}
|
||||
PIDir ldir(testLogsDir);
|
||||
for (const auto & e: ldir.entries()) {
|
||||
if (e.isFile()) PIFile::remove(e.path);
|
||||
}
|
||||
PIDir::remove(testPipelinesDir);
|
||||
PIDir::remove(testLogsDir);
|
||||
PIDir::remove(testDir);
|
||||
}
|
||||
|
||||
static Pipeline makeTestPipeline(const PIString & id = "test-id") {
|
||||
Pipeline pl;
|
||||
pl.id = id;
|
||||
pl.name = "Test Pipeline";
|
||||
pl.working_dir = "/tmp/workdir";
|
||||
pl.created_at = nowISO();
|
||||
pl.updated_at = pl.created_at;
|
||||
|
||||
Prompt p1;
|
||||
p1.id = "prompt-1";
|
||||
p1.text = "Do something";
|
||||
p1.title = "Step 1";
|
||||
p1.order = 0;
|
||||
pl.prompts << p1;
|
||||
|
||||
Prompt p2;
|
||||
p2.id = "prompt-2";
|
||||
p2.text = "Do something else";
|
||||
p2.title = "Step 2";
|
||||
p2.order = 1;
|
||||
pl.prompts << p2;
|
||||
|
||||
return pl;
|
||||
}
|
||||
|
||||
TEST(PipelineTest, SaveAndLoad) {
|
||||
setupDirs();
|
||||
Pipeline pl = makeTestPipeline();
|
||||
|
||||
EXPECT_TRUE(savePipeline(testPipelinesDir, pl));
|
||||
|
||||
PIVector<Pipeline> loaded = loadPipelines(testPipelinesDir);
|
||||
EXPECT_EQ(loaded.size(), 1);
|
||||
EXPECT_EQ(loaded[0].id, pl.id);
|
||||
EXPECT_EQ(loaded[0].name, pl.name);
|
||||
EXPECT_EQ(loaded[0].working_dir, pl.working_dir);
|
||||
EXPECT_EQ(loaded[0].prompts.size(), 2);
|
||||
|
||||
cleanupDirs();
|
||||
}
|
||||
|
||||
TEST(PipelineTest, FindPipeline) {
|
||||
setupDirs();
|
||||
Pipeline pl = makeTestPipeline("find-me");
|
||||
savePipeline(testPipelinesDir, pl);
|
||||
|
||||
Pipeline found = findPipeline(testPipelinesDir, "find-me");
|
||||
EXPECT_FALSE(found.id.isEmpty());
|
||||
EXPECT_EQ(found.id, "find-me");
|
||||
EXPECT_EQ(found.name, "Test Pipeline");
|
||||
|
||||
Pipeline notFound = findPipeline(testPipelinesDir, "nonexistent");
|
||||
EXPECT_TRUE(notFound.id.isEmpty());
|
||||
|
||||
cleanupDirs();
|
||||
}
|
||||
|
||||
TEST(PipelineTest, DeletePipeline) {
|
||||
setupDirs();
|
||||
Pipeline pl = makeTestPipeline("del-me");
|
||||
savePipeline(testPipelinesDir, pl);
|
||||
|
||||
EXPECT_TRUE(::deletePipeline(testPipelinesDir, "del-me"));
|
||||
EXPECT_TRUE(findPipeline(testPipelinesDir, "del-me").id.isEmpty());
|
||||
|
||||
EXPECT_FALSE(::deletePipeline(testPipelinesDir, "nonexistent"));
|
||||
|
||||
cleanupDirs();
|
||||
}
|
||||
|
||||
TEST(PipelineTest, MultiplePipelines) {
|
||||
setupDirs();
|
||||
|
||||
Pipeline pl1 = makeTestPipeline("pl-1");
|
||||
pl1.name = "Pipeline 1";
|
||||
savePipeline(testPipelinesDir, pl1);
|
||||
|
||||
Pipeline pl2 = makeTestPipeline("pl-2");
|
||||
pl2.name = "Pipeline 2";
|
||||
savePipeline(testPipelinesDir, pl2);
|
||||
|
||||
PIVector<Pipeline> loaded = loadPipelines(testPipelinesDir);
|
||||
EXPECT_EQ(loaded.size(), 2);
|
||||
|
||||
cleanupDirs();
|
||||
}
|
||||
|
||||
TEST(PipelineTest, EmptyDir) {
|
||||
setupDirs();
|
||||
PIVector<Pipeline> loaded = loadPipelines(testPipelinesDir);
|
||||
EXPECT_TRUE(loaded.isEmpty());
|
||||
cleanupDirs();
|
||||
}
|
||||
|
||||
TEST(PipelineTest, LogAppendAndRead) {
|
||||
setupDirs();
|
||||
|
||||
appendLog(testLogsDir, "run-1", "First log line");
|
||||
appendLog(testLogsDir, "run-1", "Second log line");
|
||||
appendLog(testLogsDir, "run-2", "Different run");
|
||||
|
||||
PIString log1 = readLog(testLogsDir, "run-1");
|
||||
EXPECT_TRUE(log1.contains("First log line"));
|
||||
EXPECT_TRUE(log1.contains("Second log line"));
|
||||
EXPECT_FALSE(log1.contains("Different run"));
|
||||
|
||||
PIString log2 = readLog(testLogsDir, "run-2");
|
||||
EXPECT_TRUE(log2.contains("Different run"));
|
||||
EXPECT_FALSE(log2.contains("First log line"));
|
||||
|
||||
PIString emptyLog = readLog(testLogsDir, "nonexistent");
|
||||
EXPECT_TRUE(emptyLog.isEmpty());
|
||||
|
||||
cleanupDirs();
|
||||
}
|
||||
|
||||
TEST(UtilTest, GenerateUUID) {
|
||||
PIString uuid1 = generateUUID();
|
||||
PIString uuid2 = generateUUID();
|
||||
|
||||
EXPECT_FALSE(uuid1.isEmpty());
|
||||
EXPECT_NE(uuid1, uuid2);
|
||||
|
||||
// UUID format: 8-4-4-4-12
|
||||
EXPECT_EQ(uuid1.find("-"), 8);
|
||||
}
|
||||
|
||||
TEST(UtilTest, NowISO) {
|
||||
PIString ts = nowISO();
|
||||
EXPECT_FALSE(ts.isEmpty());
|
||||
// ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ
|
||||
EXPECT_EQ(ts.find("-"), 4);
|
||||
EXPECT_TRUE(ts.contains("T"));
|
||||
EXPECT_TRUE(ts.endsWith("Z"));
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
#include "pipeline.h"
|
||||
#include "runner.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <pidir.h>
|
||||
#include <pifile.h>
|
||||
#include <piprocess.h>
|
||||
#include <pisystemtime.h>
|
||||
|
||||
static const PIString testDir = "/tmp/pipeline-runner-test/";
|
||||
static const PIString testPipelinesDir = testDir + "pipelines/";
|
||||
static const PIString testLogsDir = testDir + "logs/";
|
||||
|
||||
static void setupDirs() {
|
||||
PIDir::make(testDir);
|
||||
PIDir::make(testPipelinesDir);
|
||||
PIDir::make(testLogsDir);
|
||||
}
|
||||
|
||||
static void cleanupDirs() {
|
||||
PIDir pdir(testPipelinesDir);
|
||||
for (const auto & e: pdir.entries()) {
|
||||
if (e.isFile()) PIFile::remove(e.path);
|
||||
}
|
||||
PIDir ldir(testLogsDir);
|
||||
for (const auto & e: ldir.entries()) {
|
||||
if (e.isFile()) PIFile::remove(e.path);
|
||||
}
|
||||
PIDir::remove(testPipelinesDir);
|
||||
PIDir::remove(testLogsDir);
|
||||
PIDir::remove(testDir);
|
||||
}
|
||||
|
||||
static Pipeline makeEchoPipeline() {
|
||||
Pipeline pl;
|
||||
pl.id = "echo-test";
|
||||
pl.name = "Echo Test";
|
||||
pl.working_dir = "/tmp";
|
||||
pl.created_at = nowISO();
|
||||
pl.updated_at = pl.created_at;
|
||||
|
||||
Prompt p;
|
||||
p.id = "prompt-1";
|
||||
p.text = "echo hello";
|
||||
p.title = "Echo Step";
|
||||
p.order = 0;
|
||||
pl.prompts << p;
|
||||
|
||||
return pl;
|
||||
}
|
||||
|
||||
TEST(RunnerTest, StartRunNotFound) {
|
||||
setupDirs();
|
||||
PipelineRunner runner(testPipelinesDir, testLogsDir);
|
||||
|
||||
PIString runId = runner.startRun("nonexistent");
|
||||
EXPECT_TRUE(runId.isEmpty());
|
||||
|
||||
cleanupDirs();
|
||||
}
|
||||
|
||||
TEST(RunnerTest, StartRunAndGetState) {
|
||||
setupDirs();
|
||||
PipelineRunner runner(testPipelinesDir, testLogsDir);
|
||||
|
||||
// Create a pipeline that runs 'echo hello' (uses shell command, not opencode)
|
||||
Pipeline pl;
|
||||
pl.id = "state-test";
|
||||
pl.name = "State Test";
|
||||
pl.working_dir = "/tmp";
|
||||
pl.created_at = nowISO();
|
||||
pl.updated_at = pl.created_at;
|
||||
|
||||
Prompt p;
|
||||
p.id = "p1";
|
||||
p.text = "test";
|
||||
p.title = "Step 1";
|
||||
p.order = 0;
|
||||
pl.prompts << p;
|
||||
|
||||
savePipeline(testPipelinesDir, pl);
|
||||
|
||||
PIString runId = runner.startRun("state-test");
|
||||
EXPECT_FALSE(runId.isEmpty());
|
||||
|
||||
RunState state = runner.getRunState(runId);
|
||||
EXPECT_EQ(state.run_id, runId);
|
||||
EXPECT_EQ(state.pipeline_id, "state-test");
|
||||
EXPECT_EQ(state.status, RunStatus::Running);
|
||||
EXPECT_EQ(state.steps.size(), 1);
|
||||
EXPECT_EQ(state.steps[0].title, "Step 1");
|
||||
|
||||
cleanupDirs();
|
||||
}
|
||||
|
||||
TEST(RunnerTest, GetStateNotFound) {
|
||||
setupDirs();
|
||||
PipelineRunner runner(testPipelinesDir, testLogsDir);
|
||||
|
||||
RunState state = runner.getRunState("nonexistent");
|
||||
EXPECT_TRUE(state.run_id.isEmpty());
|
||||
|
||||
cleanupDirs();
|
||||
}
|
||||
|
||||
TEST(RunnerTest, IsRunActive) {
|
||||
setupDirs();
|
||||
PipelineRunner runner(testPipelinesDir, testLogsDir);
|
||||
|
||||
Pipeline pl;
|
||||
pl.id = "active-test";
|
||||
pl.name = "Active Test";
|
||||
pl.working_dir = "/tmp";
|
||||
pl.created_at = nowISO();
|
||||
pl.updated_at = pl.created_at;
|
||||
|
||||
Prompt p;
|
||||
p.id = "p1";
|
||||
p.text = "test";
|
||||
p.title = "Step 1";
|
||||
p.order = 0;
|
||||
pl.prompts << p;
|
||||
|
||||
savePipeline(testPipelinesDir, pl);
|
||||
|
||||
PIString runId = runner.startRun("active-test");
|
||||
EXPECT_TRUE(runner.isRunActive(runId));
|
||||
|
||||
cleanupDirs();
|
||||
}
|
||||
|
||||
TEST(RunnerTest, RunStatusToString) {
|
||||
EXPECT_EQ(runStatusToString(RunStatus::Pending), "pending");
|
||||
EXPECT_EQ(runStatusToString(RunStatus::Running), "running");
|
||||
EXPECT_EQ(runStatusToString(RunStatus::Completed), "completed");
|
||||
EXPECT_EQ(runStatusToString(RunStatus::Error), "error");
|
||||
}
|
||||
Reference in New Issue
Block a user