rewrite all to PIP
This commit is contained in:
+8
-55
@@ -1,55 +1,8 @@
|
|||||||
# Python
|
*.user
|
||||||
__pycache__/
|
/build*
|
||||||
*.py[cod]
|
/*/build*
|
||||||
*$py.class
|
/bin
|
||||||
*.so
|
/release
|
||||||
*.egg
|
*.orig
|
||||||
*.egg-info/
|
/.*
|
||||||
dist/
|
*test-results.xml
|
||||||
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/
|
|
||||||
|
|||||||
@@ -1,26 +1,38 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
- `pipeline-runner/` — single Python package (FastAPI + uvicorn). All code lives here.
|
- `pipeline-runner/` — C++ application built with PIP (Platform Independent Primitives).
|
||||||
- `pipeline-runner/app.py` — FastAPI app entrypoint with REST API + WebSocket + static file serving.
|
- `pipeline-runner/src/main.cpp` — entry point, config loading, server start.
|
||||||
- `pipeline-runner/main.py` — uvicorn dev server launcher (`python main.py`).
|
- `pipeline-runner/src/server.h/.cpp` — PIHTTPServer routes, all API endpoints.
|
||||||
- `pipeline-runner/static/index.html` — frontend UI.
|
- `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/pipelines/` — JSON files for persisted pipelines.
|
||||||
- `pipeline-runner/storage/logs/` — execution logs (one file per run).
|
- `pipeline-runner/storage/logs/` — execution logs (one file per run).
|
||||||
|
|
||||||
## Dev commands
|
## Dev commands
|
||||||
- Install deps: `cd pipeline-runner && pip install -r requirements.txt`
|
- Build: `cd pipeline-runner/build && cmake .. && make`
|
||||||
- Start dev server: `cd pipeline-runner && python main.py` (uvicorn with reload, port 8000)
|
- Run: `cd pipeline-runner/build && ./pipeline-runner ../pipeline-runner.conf`
|
||||||
- Deploy with auto-reload: `cd pipeline-runner && ./deploy.sh` (activates venv, installs deps, starts uvicorn with `--reload`)
|
- Run tests: `cd pipeline-runner/build && ./test-pipeline-runner`
|
||||||
- Run tests: `cd pipeline-runner && python -m pytest`
|
- Clean build: `rm -rf pipeline-runner/build && mkdir pipeline-runner/build && cd pipeline-runner/build && cmake .. && make`
|
||||||
- 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.
|
## API endpoints
|
||||||
- The `clean_storage` fixture auto-removes `storage/pipelines/` and `storage/logs/` before/after each test.
|
| 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
|
## 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).
|
- Pipeline stops on first step error (non-zero return code).
|
||||||
- Real-time logs are streamed over WebSocket at `/ws/{run_id}`.
|
- Each run executes in a separate `PIThread`.
|
||||||
- CORS is open (`allow_origins=["*"]`) for dev convenience.
|
- CORS headers are added to all responses (`Access-Control-Allow-Origin: *`).
|
||||||
- No lint, typecheck, or formatter tooling is configured.
|
- 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