diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 75acc485484e..394a0887b942 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -1,12 +1,7 @@ -# This is a **reuseable** workflow that builds the CLI for multiple platforms. +# This is a **reuseable** workflow that bundles the Desktop App for macOS. # It doesn't get triggered on its own. It gets used in multiple workflows: # - release.yml # - canary.yml -# -# Platform Build Strategy: -# - Linux: Uses Ubuntu runner with cross-compilation -# - macOS: Uses macOS runner with cross-compilation -# - Windows: Uses Ubuntu runner with Docker cross-compilation (same as desktop build) on: workflow_call: inputs: @@ -14,6 +9,17 @@ on: required: false default: "" type: string + # Let's allow overriding the OSes and architectures in JSON array form: + # e.g. '["ubuntu-latest","macos-latest"]' + # If no input is provided, these defaults apply. + operating-systems: + type: string + required: false + default: '["ubuntu-latest","macos-latest"]' + architectures: + type: string + required: false + default: '["x86_64","aarch64"]' ref: type: string required: false @@ -24,40 +30,17 @@ name: "Reusable workflow to build CLI" jobs: build-cli: name: Build CLI - runs-on: ${{ matrix.build-on }} + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: ${{ fromJson(inputs.operating-systems) }} + architecture: ${{ fromJson(inputs.architectures) }} include: - # Linux builds - os: ubuntu-latest - architecture: x86_64 target-suffix: unknown-linux-gnu - build-on: ubuntu-latest - use-cross: true - - os: ubuntu-latest - architecture: aarch64 - target-suffix: unknown-linux-gnu - build-on: ubuntu-latest - use-cross: true - # macOS builds - - os: macos-latest - architecture: x86_64 - target-suffix: apple-darwin - build-on: macos-latest - use-cross: true - os: macos-latest - architecture: aarch64 target-suffix: apple-darwin - build-on: macos-latest - use-cross: true - # Windows builds (only x86_64 supported) - - os: windows - architecture: x86_64 - target-suffix: pc-windows-gnu - build-on: ubuntu-latest - use-cross: false - use-docker: true steps: - name: Checkout code @@ -73,7 +56,6 @@ jobs: rm -f Cargo.toml.bak - name: Install cross - if: matrix.use-cross run: source ./bin/activate-hermit && cargo install cross --git https://github.com/cross-rs/cross # Install Go for building temporal-service @@ -82,32 +64,7 @@ jobs: with: go-version: '1.21' - # Cache Cargo registry and git dependencies for Windows builds - - name: Cache Cargo registry (Windows) - if: matrix.use-docker - uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f - with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-registry- - - # Cache compiled dependencies (target/release/deps) for Windows builds - - name: Cache Cargo build (Windows) - if: matrix.use-docker - uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f - with: - path: target - key: ${{ runner.os }}-cargo-build-${{ hashFiles('Cargo.lock') }}-${{ hashFiles('rust-toolchain.toml') }} - restore-keys: | - ${{ runner.os }}-cargo-build-${{ hashFiles('Cargo.lock') }}- - ${{ runner.os }}-cargo-build- - - - name: Build CLI (Linux/macOS) - if: matrix.use-cross + - name: Build CLI env: CROSS_NO_WARNINGS: 0 RUST_LOG: debug @@ -126,111 +83,7 @@ jobs: echo "Building with explicit PROTOC path..." cross build --release --target ${TARGET} -p goose-cli -vv - - name: Build CLI (Windows) - if: matrix.use-docker - run: | - echo "🚀 Building Windows CLI executable with enhanced GitHub Actions caching..." - - # Create cache directories - mkdir -p ~/.cargo/registry ~/.cargo/git - - # Use enhanced caching with GitHub Actions cache mounts - docker run --rm \ - -v "$(pwd)":/usr/src/myapp \ - -v "$HOME/.cargo/registry":/usr/local/cargo/registry \ - -v "$HOME/.cargo/git":/usr/local/cargo/git \ - -w /usr/src/myapp \ - rust:latest \ - bash -c " - set -e - echo '=== Setting up Rust environment with caching ===' - export CARGO_HOME=/usr/local/cargo - export PATH=/usr/local/cargo/bin:\$PATH - - # Check if Windows target is already installed in cache - if rustup target list --installed | grep -q x86_64-pc-windows-gnu; then - echo '✅ Windows cross-compilation target already installed' - else - echo '📦 Installing Windows cross-compilation target...' - rustup target add x86_64-pc-windows-gnu - fi - - echo '=== Setting up build dependencies ===' - apt-get update - apt-get install -y mingw-w64 protobuf-compiler cmake time - - echo '=== Setting up cross-compilation environment ===' - export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc - export CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++ - export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar - export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc - export PKG_CONFIG_ALLOW_CROSS=1 - export PROTOC=/usr/bin/protoc - - echo '=== Optimized Cargo configuration ===' - mkdir -p .cargo - echo '[build]' > .cargo/config.toml - echo 'jobs = 4' >> .cargo/config.toml - echo '' >> .cargo/config.toml - echo '[target.x86_64-pc-windows-gnu]' >> .cargo/config.toml - echo 'linker = \"x86_64-w64-mingw32-gcc\"' >> .cargo/config.toml - echo '' >> .cargo/config.toml - echo '[net]' >> .cargo/config.toml - echo 'git-fetch-with-cli = true' >> .cargo/config.toml - echo 'retry = 3' >> .cargo/config.toml - echo '' >> .cargo/config.toml - echo '[profile.release]' >> .cargo/config.toml - echo 'codegen-units = 1' >> .cargo/config.toml - echo 'lto = false' >> .cargo/config.toml - echo 'panic = \"abort\"' >> .cargo/config.toml - echo 'debug = false' >> .cargo/config.toml - echo 'opt-level = 2' >> .cargo/config.toml - echo '' >> .cargo/config.toml - echo '[registries.crates-io]' >> .cargo/config.toml - echo 'protocol = \"sparse\"' >> .cargo/config.toml - - echo '=== Building with cached dependencies ===' - # Check if we have cached build artifacts - if [ -d target/x86_64-pc-windows-gnu/release/deps ] && [ \"\$(ls -A target/x86_64-pc-windows-gnu/release/deps)\" ]; then - echo '✅ Found cached build artifacts, performing incremental build...' - CARGO_INCREMENTAL=1 - else - echo '🔨 No cached artifacts found, performing full build...' - CARGO_INCREMENTAL=0 - fi - - echo '🔨 Building Windows CLI executable...' - CARGO_INCREMENTAL=\$CARGO_INCREMENTAL \ - CARGO_NET_RETRY=3 \ - CARGO_HTTP_TIMEOUT=60 \ - RUST_BACKTRACE=1 \ - cargo build --release --target x86_64-pc-windows-gnu -p goose-cli --jobs 4 - - echo '=== Copying Windows runtime DLLs ===' - GCC_DIR=\$(ls -d /usr/lib/gcc/x86_64-w64-mingw32/*/ | head -n 1) - cp \"\$GCC_DIR/libstdc++-6.dll\" target/x86_64-pc-windows-gnu/release/ - cp \"\$GCC_DIR/libgcc_s_seh-1.dll\" target/x86_64-pc-windows-gnu/release/ - cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll target/x86_64-pc-windows-gnu/release/ - - echo '✅ Build completed successfully!' - ls -la target/x86_64-pc-windows-gnu/release/ - " - - # Verify build succeeded - if [ ! -f "./target/x86_64-pc-windows-gnu/release/goose.exe" ]; then - echo "❌ Windows CLI binary not found." - ls -la ./target/x86_64-pc-windows-gnu/release/ || echo "Release directory doesn't exist" - exit 1 - fi - - echo "✅ Windows CLI binary found!" - ls -la ./target/x86_64-pc-windows-gnu/release/goose.exe - - echo "✅ Windows runtime DLLs:" - ls -la ./target/x86_64-pc-windows-gnu/release/*.dll - - - name: Build temporal-service for target platform using build.sh script (Linux/macOS) - if: matrix.use-cross + - name: Build temporal-service for target platform run: | source ./bin/activate-hermit export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}" @@ -263,101 +116,12 @@ jobs: ;; esac - echo "Building temporal-service for ${GOOS}/${GOARCH} using build.sh script..." + echo "Building temporal-service for ${GOOS}/${GOARCH}..." cd temporal-service - # Run build.sh with cross-compilation environment - GOOS="${GOOS}" GOARCH="${GOARCH}" ./build.sh - # Move the built binary to the expected location - mv "${BINARY_NAME}" "../target/${TARGET}/release/${BINARY_NAME}" + go build -o "../target/${TARGET}/release/${BINARY_NAME}" main.go echo "temporal-service built successfully for ${TARGET}" - - name: Build temporal-service for Windows - if: matrix.use-docker - run: | - echo "Building temporal-service for Windows using build.sh script..." - docker run --rm \ - -v "$(pwd)":/usr/src/myapp \ - -w /usr/src/myapp/temporal-service \ - golang:latest \ - sh -c " - # Make build.sh executable - chmod +x build.sh - # Set Windows build environment and run build script - GOOS=windows GOARCH=amd64 ./build.sh - " - - # Move the built binary to the expected location - mkdir -p target/x86_64-pc-windows-gnu/release - mv temporal-service/temporal-service.exe target/x86_64-pc-windows-gnu/release/temporal-service.exe - echo "temporal-service.exe built successfully for Windows" - - - name: Download temporal CLI (Linux/macOS) - if: matrix.use-cross - run: | - export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}" - TEMPORAL_VERSION="1.3.0" - - # Set platform-specific download parameters - case "${TARGET}" in - "x86_64-unknown-linux-gnu") - TEMPORAL_OS="linux" - TEMPORAL_ARCH="amd64" - TEMPORAL_EXT="" - ;; - "aarch64-unknown-linux-gnu") - TEMPORAL_OS="linux" - TEMPORAL_ARCH="arm64" - TEMPORAL_EXT="" - ;; - "x86_64-apple-darwin") - TEMPORAL_OS="darwin" - TEMPORAL_ARCH="amd64" - TEMPORAL_EXT="" - ;; - "aarch64-apple-darwin") - TEMPORAL_OS="darwin" - TEMPORAL_ARCH="arm64" - TEMPORAL_EXT="" - ;; - *) - echo "Unsupported target for temporal CLI: ${TARGET}" - exit 1 - ;; - esac - - echo "Downloading temporal CLI for ${TEMPORAL_OS}/${TEMPORAL_ARCH}..." - TEMPORAL_FILE="temporal_cli_${TEMPORAL_VERSION}_${TEMPORAL_OS}_${TEMPORAL_ARCH}.tar.gz" - curl -L "https://github.com/temporalio/cli/releases/download/v${TEMPORAL_VERSION}/${TEMPORAL_FILE}" -o "${TEMPORAL_FILE}" - - # Extract temporal CLI - tar -xzf "${TEMPORAL_FILE}" - chmod +x temporal${TEMPORAL_EXT} - - # Move to target directory - mv temporal${TEMPORAL_EXT} "target/${TARGET}/release/temporal${TEMPORAL_EXT}" - - # Clean up - rm -f "${TEMPORAL_FILE}" - echo "temporal CLI downloaded successfully for ${TARGET}" - - - name: Download temporal CLI (Windows) - if: matrix.use-docker - run: | - TEMPORAL_VERSION="1.3.0" - echo "Downloading temporal CLI for Windows..." - curl -L "https://github.com/temporalio/cli/releases/download/v${TEMPORAL_VERSION}/temporal_cli_${TEMPORAL_VERSION}_windows_amd64.zip" -o temporal-cli-windows.zip - unzip -o temporal-cli-windows.zip - chmod +x temporal.exe - - # Move to target directory - mv temporal.exe target/x86_64-pc-windows-gnu/release/temporal.exe - - # Clean up - rm -f temporal-cli-windows.zip - echo "temporal CLI downloaded successfully for Windows" - - - name: Package CLI with temporal-service (Linux/macOS) - if: matrix.use-cross + - name: Package CLI with temporal-service run: | source ./bin/activate-hermit export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}" @@ -368,34 +132,12 @@ jobs: # Copy binaries cp "target/${TARGET}/release/goose" "target/${TARGET}/release/goose-package/" cp "target/${TARGET}/release/temporal-service" "target/${TARGET}/release/goose-package/" - cp "target/${TARGET}/release/temporal" "target/${TARGET}/release/goose-package/" - # Create the tar archive with all binaries + # Create the tar archive with both binaries cd "target/${TARGET}/release" tar -cjf "goose-${TARGET}.tar.bz2" -C goose-package . echo "ARTIFACT=target/${TARGET}/release/goose-${TARGET}.tar.bz2" >> $GITHUB_ENV - - name: Package CLI with temporal-service (Windows) - if: matrix.use-docker - run: | - export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}" - - # Create a directory for the package contents - mkdir -p "target/${TARGET}/release/goose-package" - - # Copy binaries - cp "target/${TARGET}/release/goose.exe" "target/${TARGET}/release/goose-package/" - cp "target/${TARGET}/release/temporal-service.exe" "target/${TARGET}/release/goose-package/" - cp "target/${TARGET}/release/temporal.exe" "target/${TARGET}/release/goose-package/" - - # Copy Windows runtime DLLs - cp "target/${TARGET}/release/"*.dll "target/${TARGET}/release/goose-package/" - - # Create the zip archive with all binaries and DLLs - cd "target/${TARGET}/release" - zip -r "goose-${TARGET}.zip" goose-package/ - echo "ARTIFACT=target/${TARGET}/release/goose-${TARGET}.zip" >> $GITHUB_ENV - - name: Upload CLI artifact uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # pin@v4 with: diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index b6b9d714338a..b52a041d3f12 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -150,12 +150,13 @@ jobs: rustup target add x86_64-apple-darwin cargo build --release -p goose-server --target x86_64-apple-darwin - # Build temporal-service using build.sh script + # Build temporal-service - name: Build temporal-service run: | - echo "Building temporal-service using build.sh script..." + echo "Building temporal-service..." cd temporal-service - ./build.sh + go build -o temporal-service main.go + chmod +x temporal-service echo "temporal-service built successfully" # Install and prepare temporal CLI diff --git a/.github/workflows/bundle-desktop-linux.yml b/.github/workflows/bundle-desktop-linux.yml index f31f80b4c42b..753c9c8c1572 100644 --- a/.github/workflows/bundle-desktop-linux.yml +++ b/.github/workflows/bundle-desktop-linux.yml @@ -142,21 +142,7 @@ jobs: restore-keys: | ${{ runner.os }}-cargo-build- - # 8) Set up Go for building temporal-service - - name: Set up Go - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # pin@v5 - with: - go-version: '1.21' - - # 9) Build temporal-service using build.sh script - - name: Build temporal-service - run: | - echo "Building temporal-service using build.sh script..." - cd temporal-service - ./build.sh - echo "temporal-service built successfully" - - # 10) Build the Rust goosed binary + # 8) Build the Rust goosed binary - name: Build goosed binary run: | echo "Building goosed binary for Linux..." @@ -164,7 +150,7 @@ jobs: ls -la target/release/ file target/release/goosed - # 11) Clean up build artifacts to save space + # 9) Clean up build artifacts to save space - name: Clean up build artifacts run: | echo "Cleaning up to save disk space..." @@ -181,18 +167,16 @@ jobs: # Check disk space df -h - # 12) Copy binaries to Electron folder - - name: Copy binaries into Electron folder + # 10) Copy binary to Electron folder + - name: Copy binary into Electron folder run: | - echo "Copying binaries to ui/desktop/src/bin/" + echo "Copying goosed binary to ui/desktop/src/bin/" mkdir -p ui/desktop/src/bin cp target/release/goosed ui/desktop/src/bin/ - cp temporal-service/temporal-service ui/desktop/src/bin/ chmod +x ui/desktop/src/bin/goosed - chmod +x ui/desktop/src/bin/temporal-service ls -la ui/desktop/src/bin/ - # 13) Final cleanup before npm build + # 10a) Final cleanup before npm build - name: Final cleanup before npm build run: | echo "Final cleanup before npm build..." @@ -204,7 +188,7 @@ jobs: # Check final disk space df -h - # 14) Install npm dependencies + # 12) Install npm dependencies - name: Install npm dependencies run: | cd ui/desktop @@ -215,7 +199,7 @@ jobs: # Verify installation ls -la node_modules/.bin/ | head -5 - # 15) Build Electron app with Linux makers (.deb and .rpm) + # 13) Build Electron app with Linux makers (.deb and .rpm) - name: Build Linux packages run: | cd ui/desktop @@ -228,7 +212,7 @@ jobs: ls -la out/ find out/ -name "*.deb" -o -name "*.rpm" | head -10 - # 16) List all generated files for debugging + # 14) List all generated files for debugging - name: List generated files run: | echo "=== All files in out/ directory ===" @@ -240,7 +224,7 @@ jobs: echo "=== File sizes ===" find ui/desktop/out/ -name "*.deb" -o -name "*.rpm" -exec ls -lh {} \; - # 17) Upload .deb package + # 15) Upload .deb package - name: Upload .deb package uses: actions/upload-artifact@v4 with: @@ -248,7 +232,7 @@ jobs: path: ui/desktop/out/make/deb/x64/*.deb if-no-files-found: error - # 18) Upload .rpm package + # 16) Upload .rpm package - name: Upload .rpm package uses: actions/upload-artifact@v4 with: @@ -256,7 +240,7 @@ jobs: path: ui/desktop/out/make/rpm/x64/*.rpm if-no-files-found: error - # 19) Create combined artifact with both packages + # 17) Create combined artifact with both packages - name: Upload combined Linux packages uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml index 37b2cbbf98eb..9aa51cf1a4f0 100644 --- a/.github/workflows/bundle-desktop-windows.yml +++ b/.github/workflows/bundle-desktop-windows.yml @@ -196,20 +196,15 @@ jobs: ls -la ./target/x86_64-pc-windows-gnu/release/goosed.exe ls -la ./target/x86_64-pc-windows-gnu/release/*.dll - # 4.5) Build temporal-service for Windows using build.sh script + # 4.5) Build temporal-service for Windows - name: Build temporal-service for Windows run: | - echo "Building temporal-service for Windows using build.sh script..." + echo "Building temporal-service for Windows..." docker run --rm \ -v "$(pwd)":/usr/src/myapp \ -w /usr/src/myapp/temporal-service \ golang:latest \ - sh -c " - # Make build.sh executable - chmod +x build.sh - # Set Windows build environment and run build script - GOOS=windows GOARCH=amd64 ./build.sh - " + sh -c "GOOS=windows GOARCH=amd64 go build -o temporal-service.exe main.go" echo "temporal-service.exe built successfully" # 4.6) Download temporal CLI for Windows diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index b015303c74f4..d11e9dea0a8c 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -190,12 +190,13 @@ jobs: - name: Build goosed run: source ./bin/activate-hermit && cargo build --release -p goose-server - # Build temporal-service using build.sh script + # Build temporal-service - name: Build temporal-service run: | - echo "Building temporal-service using build.sh script..." + echo "Building temporal-service..." cd temporal-service - ./build.sh + go build -o temporal-service main.go + chmod +x temporal-service echo "temporal-service built successfully" # Install and prepare temporal CLI diff --git a/.gitignore b/.gitignore index caab83d726c7..eb4236362092 100644 --- a/.gitignore +++ b/.gitignore @@ -50,8 +50,3 @@ benchconf.json scripts/fake.sh do_not_version/ /ui/desktop/src/bin/temporal -/temporal-service/temporal.db -/ui/desktop/src/bin/temporal.db -/temporal.db -/ui/desktop/src/bin/goose-scheduler-executor -/ui/desktop/src/bin/goose diff --git a/Cargo.lock b/Cargo.lock index 4001954600bc..2a9ae0718f06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3640,6 +3640,22 @@ dependencies = [ "xcap", ] +[[package]] +name = "goose-scheduler-executor" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap 4.5.31", + "futures", + "goose", + "mcp-core", + "serde_json", + "serde_yaml", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "goose-server" version = "1.0.30" diff --git a/Justfile b/Justfile index 9641cf285d54..1d06c817fc64 100644 --- a/Justfile +++ b/Justfile @@ -59,13 +59,6 @@ copy-binary BUILD_MODE="release": echo "Binary not found in target/{{BUILD_MODE}}"; \ exit 1; \ fi - @if [ -f ./target/{{BUILD_MODE}}/goose ]; then \ - echo "Copying goose CLI binary from target/{{BUILD_MODE}}..."; \ - cp -p ./target/{{BUILD_MODE}}/goose ./ui/desktop/src/bin/; \ - else \ - echo "Goose CLI binary not found in target/{{BUILD_MODE}}"; \ - exit 1; \ - fi @if [ -f ./temporal-service/temporal-service ]; then \ echo "Copying temporal-service binary..."; \ cp -p ./temporal-service/temporal-service ./ui/desktop/src/bin/; \ @@ -90,13 +83,6 @@ copy-binary-intel: echo "Intel release binary not found."; \ exit 1; \ fi - @if [ -f ./target/x86_64-apple-darwin/release/goose ]; then \ - echo "Copying Intel goose CLI binary to ui/desktop/src/bin..."; \ - cp -p ./target/x86_64-apple-darwin/release/goose ./ui/desktop/src/bin/; \ - else \ - echo "Intel goose CLI binary not found."; \ - exit 1; \ - fi @if [ -f ./temporal-service/temporal-service ]; then \ echo "Copying temporal-service binary..."; \ cp -p ./temporal-service/temporal-service ./ui/desktop/src/bin/; \ @@ -122,12 +108,6 @@ copy-binary-windows: Write-Host 'Windows binary not found.' -ForegroundColor Red; \ exit 1; \ }" - @powershell.exe -Command "if (Test-Path ./target/x86_64-pc-windows-gnu/release/goose-scheduler-executor.exe) { \ - Write-Host 'Copying Windows goose-scheduler-executor binary...'; \ - Copy-Item -Path './target/x86_64-pc-windows-gnu/release/goose-scheduler-executor.exe' -Destination './ui/desktop/src/bin/' -Force; \ - } else { \ - Write-Host 'Windows goose-scheduler-executor binary not found.' -ForegroundColor Yellow; \ - }" @if [ -f ./temporal-service/temporal-service.exe ]; then \ echo "Copying Windows temporal-service binary..."; \ cp -p ./temporal-service/temporal-service.exe ./ui/desktop/src/bin/; \ @@ -154,12 +134,6 @@ run-ui-alpha temporal="true": @echo "Running UI with {{ if temporal == "true" { "Temporal" } else { "Legacy" } }} scheduler..." cd ui/desktop && npm install && ALPHA=true GOOSE_SCHEDULER_TYPE={{ if temporal == "true" { "temporal" } else { "legacy" } }} npm run start-alpha-gui -# Run UI with alpha changes using legacy scheduler (no Temporal dependency) -run-ui-alpha-legacy: - @just release-binary - @echo "Running UI with Legacy scheduler (no Temporal required)..." - cd ui/desktop && npm install && ALPHA=true GOOSE_SCHEDULER_TYPE=legacy npm run start-alpha-gui - # Run UI with latest (Windows version) run-ui-windows: @just release-windows @@ -186,11 +160,6 @@ make-ui: @just release-binary cd ui/desktop && npm run bundle:default -# make GUI with latest binary and alpha features enabled -make-ui-alpha: - @just release-binary - cd ui/desktop && npm run bundle:alpha - # make GUI with latest Windows binary make-ui-windows: @just release-windows @@ -203,8 +172,25 @@ make-ui-windows: echo "Copying Windows binary and DLLs..." && \ cp -f ./target/x86_64-pc-windows-gnu/release/goosed.exe ./ui/desktop/src/bin/ && \ cp -f ./target/x86_64-pc-windows-gnu/release/*.dll ./ui/desktop/src/bin/ && \ + if [ -d "./ui/desktop/src/platform/windows/bin" ]; then \ + echo "Copying Windows platform files..." && \ + for file in ./ui/desktop/src/platform/windows/bin/*.{exe,dll,cmd}; do \ + if [ -f "$file" ] && [ "$(basename "$file")" != "goosed.exe" ]; then \ + cp -f "$file" ./ui/desktop/src/bin/; \ + fi; \ + done && \ + if [ -d "./ui/desktop/src/platform/windows/bin/goose-npm" ]; then \ + echo "Setting up npm environment..." && \ + rsync -a --delete ./ui/desktop/src/platform/windows/bin/goose-npm/ ./ui/desktop/src/bin/goose-npm/; \ + fi && \ + echo "Windows-specific files copied successfully"; \ + fi && \ echo "Starting Windows package build..." && \ - (cd ui/desktop && npm run bundle:windows) && \ + (cd ui/desktop && echo "In desktop directory, running npm bundle:windows..." && npm run bundle:windows) && \ + echo "Creating resources directory..." && \ + (cd ui/desktop && mkdir -p out/Goose-win32-x64/resources/bin) && \ + echo "Copying final binaries..." && \ + (cd ui/desktop && rsync -av src/bin/ out/Goose-win32-x64/resources/bin/) && \ echo "Windows package build complete!"; \ else \ echo "Windows binary not found."; \ @@ -216,50 +202,10 @@ make-ui-intel: @just release-intel cd ui/desktop && npm run bundle:intel -# Start Temporal services (server and temporal-service) -start-temporal: - @echo "Starting Temporal server..." - @if ! pgrep -f "temporal server start-dev" > /dev/null; then \ - echo "Starting Temporal server in background..."; \ - nohup temporal server start-dev --db-filename temporal.db --port 7233 --ui-port 8233 --log-level warn > temporal-server.log 2>&1 & \ - echo "Waiting for Temporal server to start..."; \ - sleep 5; \ - else \ - echo "Temporal server is already running"; \ - fi - @echo "Starting temporal-service..." - @if ! pgrep -f "temporal-service" > /dev/null; then \ - echo "Starting temporal-service in background..."; \ - cd temporal-service && nohup ./temporal-service > temporal-service.log 2>&1 & \ - echo "Waiting for temporal-service to start..."; \ - sleep 3; \ - else \ - echo "temporal-service is already running"; \ - fi - @echo "Temporal services started. Check logs: temporal-server.log, temporal-service/temporal-service.log" - -# Stop Temporal services -stop-temporal: - @echo "Stopping Temporal services..." - @pkill -f "temporal server start-dev" || echo "Temporal server was not running" - @pkill -f "temporal-service" || echo "temporal-service was not running" - @echo "Temporal services stopped" - -# Check status of Temporal services -status-temporal: - @echo "Checking Temporal services status..." - @if pgrep -f "temporal server start-dev" > /dev/null; then \ - echo "✓ Temporal server is running"; \ - else \ - echo "✗ Temporal server is not running"; \ - fi - @if pgrep -f "temporal-service" > /dev/null; then \ - echo "✓ temporal-service is running"; \ - else \ - echo "✗ temporal-service is not running"; \ - fi - @echo "Testing temporal-service health..." - @curl -s http://localhost:8080/health > /dev/null && echo "✓ temporal-service is responding" || echo "✗ temporal-service is not responding" +# Setup langfuse server +langfuse-server: + #!/usr/bin/env bash + ./scripts/setup_langfuse.sh # Run UI with debug build run-dev: diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index da00f89e1e2c..f1082f087ff3 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -31,7 +31,6 @@ tokio = { version = "1.43", features = ["full"] } futures = "0.3" serde = { version = "1.0", features = ["derive"] } # For serialization serde_yaml = "0.9" -tempfile = "3" etcetera = "0.8.0" reqwest = { version = "0.12.9", features = [ "rustls-tls-native-roots", diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 3cfc71809c5f..2e1c7b4445f0 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -509,16 +509,6 @@ enum Command { help = "Quiet mode. Suppress non-response output, printing only the model response to stdout" )] quiet: bool, - - /// Scheduled job ID (used internally for scheduled executions) - #[arg( - long = "scheduled-job-id", - value_name = "ID", - help = "ID of the scheduled job that triggered this execution (internal use)", - long_help = "Internal parameter used when this run command is executed by a scheduled job. This associates the session with the schedule for tracking purposes.", - hide = true - )] - scheduled_job_id: Option, }, /// Recipe utilities for validation and deeplinking @@ -674,7 +664,6 @@ pub async fn cli() -> Result<()> { settings: None, debug, max_tool_repetitions, - scheduled_job_id: None, interactive: true, quiet: false, sub_recipes: None, @@ -723,7 +712,6 @@ pub async fn cli() -> Result<()> { params, explain, render_recipe, - scheduled_job_id, quiet, }) => { let (input_config, session_settings, sub_recipes) = match ( @@ -827,7 +815,6 @@ pub async fn cli() -> Result<()> { settings: session_settings, debug, max_tool_repetitions, - scheduled_job_id, interactive, // Use the interactive flag from the Run command quiet, sub_recipes, @@ -946,7 +933,6 @@ pub async fn cli() -> Result<()> { settings: None::, debug: false, max_tool_repetitions: None, - scheduled_job_id: None, interactive: true, // Default case is always interactive quiet: false, sub_recipes: None, diff --git a/crates/goose-cli/src/commands/bench.rs b/crates/goose-cli/src/commands/bench.rs index 83d485626399..b0c18677811a 100644 --- a/crates/goose-cli/src/commands/bench.rs +++ b/crates/goose-cli/src/commands/bench.rs @@ -44,7 +44,6 @@ pub async fn agent_generator( debug: false, max_tool_repetitions: None, interactive: false, // Benchmarking is non-interactive - scheduled_job_id: None, quiet: false, sub_recipes: None, }) diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs index 4d94c6de8e80..771ac998ae3f 100644 --- a/crates/goose-cli/src/commands/schedule.rs +++ b/crates/goose-cli/src/commands/schedule.rs @@ -99,7 +99,6 @@ pub async fn handle_schedule_add( paused: false, current_session_id: None, process_start_time: None, - execution_mode: Some("background".to_string()), // Default to background for CLI }; let scheduler_storage_path = diff --git a/crates/goose-cli/src/commands/session.rs b/crates/goose-cli/src/commands/session.rs index e1ffe46f734b..f3fb97e7304d 100644 --- a/crates/goose-cli/src/commands/session.rs +++ b/crates/goose-cli/src/commands/session.rs @@ -175,12 +175,7 @@ pub fn handle_session_list(verbose: bool, format: String, ascending: bool) -> Re /// without creating an Agent or prompting about working directories. pub fn handle_session_export(identifier: Identifier, output_path: Option) -> Result<()> { // Get the session file path - let session_file_path = match goose::session::get_path(identifier.clone()) { - Ok(path) => path, - Err(e) => { - return Err(anyhow::anyhow!("Invalid session identifier: {}", e)); - } - }; + let session_file_path = goose::session::get_path(identifier.clone()); if !session_file_path.exists() { return Err(anyhow::anyhow!( diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs index 33e0d34df13b..cd195bf06e73 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -250,14 +250,7 @@ async fn list_sessions() -> Json { async fn get_session( axum::extract::Path(session_id): axum::extract::Path, ) -> Json { - let session_file = match session::get_path(session::Identifier::Name(session_id)) { - Ok(path) => path, - Err(e) => { - return Json(serde_json::json!({ - "error": format!("Invalid session ID: {}", e) - })); - } - }; + let session_file = session::get_path(session::Identifier::Name(session_id)); match session::read_messages(&session_file) { Ok(messages) => { @@ -295,15 +288,9 @@ async fn handle_socket(socket: WebSocket, state: AppState) { .. }) => { // Get session file path from session_id - let session_file = match session::get_path(session::Identifier::Name( + let session_file = session::get_path(session::Identifier::Name( session_id.clone(), - )) { - Ok(path) => path, - Err(e) => { - tracing::error!("Failed to get session path: {}", e); - continue; - } - }; + )); // Get or create session in memory (for fast access during processing) let session_messages = { @@ -478,7 +465,6 @@ async fn process_message_streaming( id: session::Identifier::Path(session_file.clone()), working_dir: std::env::current_dir()?, schedule_id: None, - execution_mode: None, }; // Get response from agent diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 2bbc0e709051..ebeb228ef34f 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -41,8 +41,6 @@ pub struct SessionBuilderConfig { pub debug: bool, /// Maximum number of consecutive identical tool calls allowed pub max_tool_repetitions: Option, - /// ID of the scheduled job that triggered this session (if any) - pub scheduled_job_id: Option, /// Whether this session will be used interactively (affects debugging prompts) pub interactive: bool, /// Quiet mode - suppress non-response output @@ -120,7 +118,7 @@ async fn offer_extension_debugging_help( std::env::temp_dir().join(format!("goose_debug_extension_{}.jsonl", extension_name)); // Create the debugging session - let mut debug_session = Session::new(debug_agent, temp_session_file.clone(), false, None); + let mut debug_session = Session::new(debug_agent, temp_session_file.clone(), false); // Process the debugging request println!("{}", style("Analyzing the extension failure...").yellow()); @@ -234,13 +232,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session { } } else if session_config.resume { if let Some(identifier) = session_config.identifier { - let session_file = match session::get_path(identifier) { - Ok(path) => path, - Err(e) => { - output::render_error(&format!("Invalid session identifier: {}", e)); - process::exit(1); - } - }; + let session_file = session::get_path(identifier); if !session_file.exists() { output::render_error(&format!( "Cannot resume session {} - no such session exists", @@ -268,13 +260,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session { }; // Just get the path - file will be created when needed - match session::get_path(id) { - Ok(path) => path, - Err(e) => { - output::render_error(&format!("Failed to create session path: {}", e)); - process::exit(1); - } - } + session::get_path(id) }; if session_config.resume && !session_config.no_session { @@ -361,12 +347,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session { } // Create new session - let mut session = Session::new( - agent, - session_file.clone(), - session_config.debug, - session_config.scheduled_job_id.clone(), - ); + let mut session = Session::new(agent, session_file.clone(), session_config.debug); // Add extensions if provided for extension_str in session_config.extensions { @@ -515,7 +496,6 @@ mod tests { settings: None, debug: true, max_tool_repetitions: Some(5), - scheduled_job_id: None, interactive: true, quiet: false, sub_recipes: None, @@ -526,7 +506,6 @@ mod tests { assert_eq!(config.builtins.len(), 1); assert!(config.debug); assert_eq!(config.max_tool_repetitions, Some(5)); - assert!(config.scheduled_job_id.is_none()); assert!(config.interactive); assert!(!config.quiet); } @@ -545,7 +524,6 @@ mod tests { assert!(config.additional_system_prompt.is_none()); assert!(!config.debug); assert!(config.max_tool_repetitions.is_none()); - assert!(config.scheduled_job_id.is_none()); assert!(!config.interactive); assert!(!config.quiet); } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 6f09c1ee653d..dc8892fea218 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -51,7 +51,6 @@ pub struct Session { completion_cache: Arc>, debug: bool, // New field for debug mode run_mode: RunMode, - scheduled_job_id: Option, // ID of the scheduled job that triggered this session } // Cache structure for completion data @@ -108,12 +107,7 @@ pub async fn classify_planner_response( } impl Session { - pub fn new( - agent: Agent, - session_file: PathBuf, - debug: bool, - scheduled_job_id: Option, - ) -> Self { + pub fn new(agent: Agent, session_file: PathBuf, debug: bool) -> Self { let messages = match session::read_messages(&session_file) { Ok(msgs) => msgs, Err(e) => { @@ -129,7 +123,6 @@ impl Session { completion_cache: Arc::new(std::sync::RwLock::new(CompletionCache::new())), debug, run_mode: RunMode::Normal, - scheduled_job_id, } } @@ -314,13 +307,7 @@ impl Session { let provider = self.agent.provider().await?; // Persist messages with provider for automatic description generation - session::persist_messages_with_schedule_id( - &self.session_file, - &self.messages, - Some(provider), - self.scheduled_job_id.clone(), - ) - .await?; + session::persist_messages(&self.session_file, &self.messages, Some(provider)).await?; // Track the current directory and last instruction in projects.json let session_id = self @@ -426,11 +413,10 @@ impl Session { let provider = self.agent.provider().await?; // Persist messages with provider for automatic description generation - session::persist_messages_with_schedule_id( + session::persist_messages( &self.session_file, &self.messages, Some(provider), - self.scheduled_job_id.clone(), ) .await?; @@ -614,11 +600,10 @@ impl Session { self.messages = summarized_messages; // Persist the summarized messages - session::persist_messages_with_schedule_id( + session::persist_messages( &self.session_file, &self.messages, Some(provider), - self.scheduled_job_id.clone(), ) .await?; @@ -742,8 +727,7 @@ impl Session { id: session_id.clone(), working_dir: std::env::current_dir() .expect("failed to get current session working directory"), - schedule_id: self.scheduled_job_id.clone(), - execution_mode: None, + schedule_id: None, }), ) .await?; @@ -792,7 +776,7 @@ impl Session { Err(ToolError::ExecutionError("Tool call cancelled by user".to_string())) )); self.messages.push(response_message); - session::persist_messages_with_schedule_id(&self.session_file, &self.messages, None, self.scheduled_job_id.clone()).await?; + session::persist_messages(&self.session_file, &self.messages, None).await?; drop(stream); break; @@ -878,8 +862,7 @@ impl Session { id: session_id.clone(), working_dir: std::env::current_dir() .expect("failed to get current session working directory"), - schedule_id: self.scheduled_job_id.clone(), - execution_mode: None, + schedule_id: None, }), ) .await?; @@ -889,7 +872,7 @@ impl Session { self.messages.push(message.clone()); // No need to update description on assistant messages - session::persist_messages_with_schedule_id(&self.session_file, &self.messages, None, self.scheduled_job_id.clone()).await?; + session::persist_messages(&self.session_file, &self.messages, None).await?; if interactive {output::hide_thinking()}; let _ = progress_bars.hide(); @@ -1088,13 +1071,7 @@ impl Session { self.messages.push(response_message); // No need for description update here - session::persist_messages_with_schedule_id( - &self.session_file, - &self.messages, - None, - self.scheduled_job_id.clone(), - ) - .await?; + session::persist_messages(&self.session_file, &self.messages, None).await?; let prompt = format!( "The existing call to {} was interrupted. How would you like to proceed?", @@ -1103,13 +1080,7 @@ impl Session { self.messages.push(Message::assistant().with_text(&prompt)); // No need for description update here - session::persist_messages_with_schedule_id( - &self.session_file, - &self.messages, - None, - self.scheduled_job_id.clone(), - ) - .await?; + session::persist_messages(&self.session_file, &self.messages, None).await?; output::render_message(&Message::assistant().with_text(&prompt), self.debug); } else { @@ -1123,13 +1094,8 @@ impl Session { self.messages.push(Message::assistant().with_text(prompt)); // No need for description update here - session::persist_messages_with_schedule_id( - &self.session_file, - &self.messages, - None, - self.scheduled_job_id.clone(), - ) - .await?; + session::persist_messages(&self.session_file, &self.messages, None) + .await?; output::render_message( &Message::assistant().with_text(prompt), diff --git a/crates/goose-scheduler-executor/Cargo.toml b/crates/goose-scheduler-executor/Cargo.toml new file mode 100644 index 000000000000..0178dda52b37 --- /dev/null +++ b/crates/goose-scheduler-executor/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "goose-scheduler-executor" +version = "0.1.0" +edition = "2021" + +[dependencies] +goose = { path = "../goose" } +mcp-core = { path = "../mcp-core" } +anyhow = "1.0" +tokio = { version = "1.0", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +clap = { version = "4.0", features = ["derive"] } +futures = "0.3" +serde_json = "1.0" +serde_yaml = "0.9" \ No newline at end of file diff --git a/crates/goose-scheduler-executor/src/main.rs b/crates/goose-scheduler-executor/src/main.rs new file mode 100644 index 000000000000..d9e59f5e6d1f --- /dev/null +++ b/crates/goose-scheduler-executor/src/main.rs @@ -0,0 +1,215 @@ +use anyhow::{anyhow, Result}; +use clap::Parser; +use goose::agents::{Agent, SessionConfig}; +use goose::config::Config; +use goose::message::Message; +use goose::providers::create; +use goose::recipe::Recipe; +use goose::session; +use std::env; +use std::fs; +use std::path::Path; +use tracing::info; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Job ID for the scheduled job + job_id: String, + + /// Path to the recipe file to execute + recipe_path: String, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize tracing + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let args = Args::parse(); + + info!("Starting goose-scheduler-executor for job: {}", args.job_id); + info!("Recipe path: {}", args.recipe_path); + + // Execute the recipe and get session ID + let session_id = execute_recipe(&args.job_id, &args.recipe_path).await?; + + // Output session ID to stdout (this is what the Go service expects) + println!("{}", session_id); + + Ok(()) +} + +async fn execute_recipe(job_id: &str, recipe_path: &str) -> Result { + let recipe_path_buf = Path::new(recipe_path); + + // Check if recipe file exists + if !recipe_path_buf.exists() { + return Err(anyhow!("Recipe file not found: {}", recipe_path)); + } + + // Read and parse recipe + let recipe_content = fs::read_to_string(recipe_path_buf)?; + let recipe: Recipe = { + let extension = recipe_path_buf + .extension() + .and_then(|os_str| os_str.to_str()) + .unwrap_or("yaml") + .to_lowercase(); + + match extension.as_str() { + "json" | "jsonl" => serde_json::from_str::(&recipe_content) + .map_err(|e| anyhow!("Failed to parse JSON recipe '{}': {}", recipe_path, e))?, + "yaml" | "yml" => serde_yaml::from_str::(&recipe_content) + .map_err(|e| anyhow!("Failed to parse YAML recipe '{}': {}", recipe_path, e))?, + _ => { + return Err(anyhow!( + "Unsupported recipe file extension '{}' for: {}", + extension, + recipe_path + )); + } + } + }; + + // Create agent + let agent = Agent::new(); + + // Get provider configuration + let global_config = Config::global(); + let provider_name: String = global_config.get_param("GOOSE_PROVIDER").map_err(|_| { + anyhow!("GOOSE_PROVIDER not configured. Run 'goose configure' or set env var.") + })?; + let model_name: String = global_config.get_param("GOOSE_MODEL").map_err(|_| { + anyhow!("GOOSE_MODEL not configured. Run 'goose configure' or set env var.") + })?; + + let model_config = goose::model::ModelConfig::new(model_name); + let provider = create(&provider_name, model_config) + .map_err(|e| anyhow!("Failed to create provider '{}': {}", provider_name, e))?; + + // Set provider on agent + agent + .update_provider(provider) + .await + .map_err(|e| anyhow!("Failed to set provider on agent: {}", e))?; + + info!( + "Agent configured with provider '{}' for job '{}'", + provider_name, job_id + ); + + // Generate session ID + let session_id = session::generate_session_id(); + + // Check if recipe has a prompt + let Some(prompt_text) = recipe.prompt else { + info!( + "Recipe '{}' has no prompt to execute for job '{}'", + recipe_path, job_id + ); + + // Create empty session for consistency + let session_file_path = goose::session::storage::get_path( + goose::session::storage::Identifier::Name(session_id.clone()), + ); + + let metadata = goose::session::storage::SessionMetadata { + working_dir: env::current_dir().unwrap_or_default(), + description: "Empty job - no prompt".to_string(), + schedule_id: Some(job_id.to_string()), + message_count: 0, + ..Default::default() + }; + + goose::session::storage::save_messages_with_metadata(&session_file_path, &metadata, &[]) + .map_err(|e| anyhow!("Failed to persist metadata for empty job: {}", e))?; + + return Ok(session_id); + }; + + // Create session configuration + let current_dir = + env::current_dir().map_err(|e| anyhow!("Failed to get current directory: {}", e))?; + + let session_config = SessionConfig { + id: goose::session::storage::Identifier::Name(session_id.clone()), + working_dir: current_dir.clone(), + schedule_id: Some(job_id.to_string()), + }; + + // Execute the recipe + let mut messages = vec![Message::user().with_text(prompt_text)]; + + info!("Executing recipe for job '{}' with prompt", job_id); + + let mut stream = agent + .reply(&messages, Some(session_config)) + .await + .map_err(|e| anyhow!("Agent failed to reply for recipe '{}': {}", recipe_path, e))?; + + // Process the response stream + use futures::StreamExt; + use goose::agents::AgentEvent; + + while let Some(message_result) = stream.next().await { + match message_result { + Ok(AgentEvent::Message(msg)) => { + if msg.role == mcp_core::role::Role::Assistant { + info!("[Job {}] Assistant response received", job_id); + } + messages.push(msg); + } + Ok(AgentEvent::McpNotification(_)) => { + // Handle notifications if needed + } + Ok(AgentEvent::ModelChange { .. }) => { + // Model change events are informational, just continue + } + Err(e) => { + return Err(anyhow!("Error receiving message from agent: {}", e)); + } + } + } + + // Save session + let session_file_path = goose::session::storage::get_path( + goose::session::storage::Identifier::Name(session_id.clone()), + ); + + // Try to read updated metadata, or create fallback + match goose::session::storage::read_metadata(&session_file_path) { + Ok(mut updated_metadata) => { + updated_metadata.message_count = messages.len(); + goose::session::storage::save_messages_with_metadata( + &session_file_path, + &updated_metadata, + &messages, + ) + .map_err(|e| anyhow!("Failed to persist final messages: {}", e))?; + } + Err(_) => { + let fallback_metadata = goose::session::storage::SessionMetadata { + working_dir: current_dir, + description: format!("Scheduled job: {}", job_id), + schedule_id: Some(job_id.to_string()), + message_count: messages.len(), + ..Default::default() + }; + goose::session::storage::save_messages_with_metadata( + &session_file_path, + &fallback_metadata, + &messages, + ) + .map_err(|e| anyhow!("Failed to persist messages with fallback metadata: {}", e))?; + } + } + + info!( + "Finished executing job '{}', session: {}", + job_id, session_id + ); + Ok(session_id) +} diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 81e1d7fc680e..ace1af27e793 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -40,7 +40,6 @@ struct ChatRequest { messages: Vec, session_id: Option, session_working_dir: String, - scheduled_job_id: Option, } pub struct SseResponse { @@ -182,8 +181,7 @@ async fn handler( Some(SessionConfig { id: session::Identifier::Name(session_id.clone()), working_dir: PathBuf::from(session_working_dir), - schedule_id: request.scheduled_job_id.clone(), - execution_mode: None, + schedule_id: None, }), ) .await @@ -210,20 +208,7 @@ async fn handler( }; let mut all_messages = messages.clone(); - let session_path = match session::get_path(session::Identifier::Name(session_id.clone())) { - Ok(path) => path, - Err(e) => { - tracing::error!("Failed to get session path: {}", e); - let _ = stream_event( - MessageEvent::Error { - error: format!("Failed to get session path: {}", e), - }, - &tx, - ) - .await; - return; - } - }; + let session_path = session::get_path(session::Identifier::Name(session_id.clone())); loop { tokio::select! { @@ -319,7 +304,6 @@ struct AskRequest { prompt: String, session_id: Option, session_working_dir: String, - scheduled_job_id: Option, } #[derive(Debug, Serialize)] @@ -356,8 +340,7 @@ async fn ask_handler( Some(SessionConfig { id: session::Identifier::Name(session_id.clone()), working_dir: PathBuf::from(session_working_dir), - schedule_id: request.scheduled_job_id.clone(), - execution_mode: None, + schedule_id: None, }), ) .await @@ -405,13 +388,7 @@ async fn ask_handler( all_messages.push(response_message); } - let session_path = match session::get_path(session::Identifier::Name(session_id.clone())) { - Ok(path) => path, - Err(e) => { - tracing::error!("Failed to get session path: {}", e); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - }; + let session_path = session::get_path(session::Identifier::Name(session_id.clone())); let session_path_clone = session_path.clone(); let messages = all_messages.clone(); @@ -605,7 +582,6 @@ mod tests { prompt: "test prompt".to_string(), session_id: Some("test-session".to_string()), session_working_dir: "test-working-dir".to_string(), - scheduled_job_id: None, }) .unwrap(), )) diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs index 64df30aa5625..9b4433c52ee4 100644 --- a/crates/goose-server/src/routes/schedule.rs +++ b/crates/goose-server/src/routes/schedule.rs @@ -19,8 +19,6 @@ pub struct CreateScheduleRequest { id: String, recipe_source: String, cron: String, - #[serde(default)] - execution_mode: Option, // "foreground" or "background" } #[derive(Deserialize, Serialize, utoipa::ToSchema)] @@ -126,7 +124,6 @@ async fn create_schedule( paused: false, current_session_id: None, process_start_time: None, - execution_mode: req.execution_mode.or(Some("background".to_string())), // Default to background }; scheduler .add_scheduled_job(job.clone()) diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index c5d9187666b4..edbf128f1569 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -84,10 +84,7 @@ async fn get_session_history( ) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; - let session_path = match session::get_path(session::Identifier::Name(session_id.clone())) { - Ok(path) => path, - Err(_) => return Err(StatusCode::BAD_REQUEST), - }; + let session_path = session::get_path(session::Identifier::Name(session_id.clone())); // Read metadata let metadata = session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?; diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index c2a5dbdb30ba..fb62981885b2 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -644,25 +644,7 @@ impl Agent { let (mut tools, mut toolshim_tools, mut system_prompt) = self.prepare_tools_and_prompt().await?; - // Get goose_mode from config, but override with execution_mode if provided in session config - let mut goose_mode = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string()); - - // If this is a scheduled job with an execution_mode, override the goose_mode - if let Some(session_config) = &session { - if let Some(execution_mode) = &session_config.execution_mode { - // Map "foreground" to "auto" and "background" to "chat" - goose_mode = match execution_mode.as_str() { - "foreground" => "auto".to_string(), - "background" => "chat".to_string(), - _ => goose_mode, - }; - tracing::info!( - "Using execution_mode '{}' which maps to goose_mode '{}'", - execution_mode, - goose_mode - ); - } - } + let goose_mode = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string()); let (tools_with_readonly_annotation, tools_without_annotation) = Self::categorize_tools_by_annotation(&tools); diff --git a/crates/goose/src/agents/platform_tools.rs b/crates/goose/src/agents/platform_tools.rs index 841c18d43f9e..01f59f958fd8 100644 --- a/crates/goose/src/agents/platform_tools.rs +++ b/crates/goose/src/agents/platform_tools.rs @@ -143,8 +143,7 @@ pub fn manage_schedule_tool() -> Tool { }, "job_id": {"type": "string", "description": "Job identifier for operations on existing jobs"}, "recipe_path": {"type": "string", "description": "Path to recipe file for create action"}, - "cron_expression": {"type": "string", "description": "A cron expression for create action. Supports both 5-field (minute hour day month weekday) and 6-field (second minute hour day month weekday) formats. 5-field expressions are automatically converted to 6-field by prepending '0' for seconds."}, - "execution_mode": {"type": "string", "description": "Execution mode for create action: 'foreground' or 'background'", "enum": ["foreground", "background"], "default": "background"}, + "cron_expression": {"type": "string", "description": "A six field cron expression for create action"}, "limit": {"type": "integer", "description": "Limit for sessions list", "default": 50}, "session_id": {"type": "string", "description": "Session identifier for session_content action"} } diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 573e9a93b118..9e37960e7ec6 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -222,12 +222,7 @@ impl Agent { usage: &crate::providers::base::ProviderUsage, messages_length: usize, ) -> Result<()> { - let session_file_path = match session::storage::get_path(session_config.id.clone()) { - Ok(path) => path, - Err(e) => { - return Err(anyhow::anyhow!("Failed to get session file path: {}", e)); - } - }; + let session_file_path = session::storage::get_path(session_config.id.clone()); let mut metadata = session::storage::read_metadata(&session_file_path)?; metadata.schedule_id = session_config.schedule_id.clone(); diff --git a/crates/goose/src/agents/schedule_tool.rs b/crates/goose/src/agents/schedule_tool.rs index 043866372596..1dac50bed51d 100644 --- a/crates/goose/src/agents/schedule_tool.rs +++ b/crates/goose/src/agents/schedule_tool.rs @@ -94,20 +94,6 @@ impl Agent { ToolError::ExecutionError("Missing 'cron_expression' parameter".to_string()) })?; - // Get the execution_mode parameter, defaulting to "background" if not provided - let execution_mode = arguments - .get("execution_mode") - .and_then(|v| v.as_str()) - .unwrap_or("background"); - - // Validate execution_mode is either "foreground" or "background" - if execution_mode != "foreground" && execution_mode != "background" { - return Err(ToolError::ExecutionError(format!( - "Invalid execution_mode: {}. Must be 'foreground' or 'background'", - execution_mode - ))); - } - // Validate recipe file exists and is readable if !std::path::Path::new(recipe_path).exists() { return Err(ToolError::ExecutionError(format!( @@ -149,13 +135,12 @@ impl Agent { paused: false, current_session_id: None, process_start_time: None, - execution_mode: Some(execution_mode.to_string()), }; match scheduler.add_scheduled_job(job).await { Ok(()) => Ok(vec![Content::text(format!( - "Successfully created scheduled job '{}' for recipe '{}' with cron expression '{}' in {} mode", - job_id, recipe_path, cron_expression, execution_mode + "Successfully created scheduled job '{}' for recipe '{}' with cron expression '{}'", + job_id, recipe_path, cron_expression ))]), Err(e) => Err(ToolError::ExecutionError(format!( "Failed to create job: {}", @@ -372,17 +357,9 @@ impl Agent { })?; // Get the session file path - let session_path = match crate::session::storage::get_path( + let session_path = crate::session::storage::get_path( crate::session::storage::Identifier::Name(session_id.to_string()), - ) { - Ok(path) => path, - Err(e) => { - return Err(ToolError::ExecutionError(format!( - "Invalid session ID '{}': {}", - session_id, e - ))); - } - }; + ); // Check if session file exists if !session_path.exists() { diff --git a/crates/goose/src/agents/types.rs b/crates/goose/src/agents/types.rs index 32c4b15e1dd3..9d23150a5191 100644 --- a/crates/goose/src/agents/types.rs +++ b/crates/goose/src/agents/types.rs @@ -23,7 +23,5 @@ pub struct SessionConfig { /// Working directory for the session pub working_dir: PathBuf, /// ID of the schedule that triggered this session, if any - pub schedule_id: Option, - /// Execution mode for scheduled jobs: "foreground" or "background" - pub execution_mode: Option, + pub schedule_id: Option, // NEW } diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index d6eba4650d91..5e6c8459cf09 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -157,8 +157,6 @@ pub struct ScheduledJob { pub current_session_id: Option, #[serde(default)] pub process_start_time: Option>, - #[serde(default)] - pub execution_mode: Option, // "foreground" or "background" } async fn persist_jobs_from_arc( @@ -1131,10 +1129,6 @@ async fn run_scheduled_job_internal( } tracing::info!("Agent configured with provider for job '{}'", job.id); - // Log the execution mode - let execution_mode = job.execution_mode.as_deref().unwrap_or("background"); - tracing::info!("Job '{}' running in {} mode", job.id, execution_mode); - let session_id_for_return = session::generate_session_id(); // Update the job with the session ID if we have access to the jobs arc @@ -1145,17 +1139,9 @@ async fn run_scheduled_job_internal( } } - let session_file_path = match crate::session::storage::get_path( + let session_file_path = crate::session::storage::get_path( crate::session::storage::Identifier::Name(session_id_for_return.clone()), - ) { - Ok(path) => path, - Err(e) => { - return Err(JobExecutionError { - job_id: job.id.clone(), - error: format!("Failed to get session file path: {}", e), - }); - } - }; + ); if let Some(prompt_text) = recipe.prompt { let mut all_session_messages: Vec = @@ -1175,7 +1161,6 @@ async fn run_scheduled_job_internal( id: crate::session::storage::Identifier::Name(session_id_for_return.clone()), working_dir: current_dir.clone(), schedule_id: Some(job.id.clone()), - execution_mode: job.execution_mode.clone(), }; match agent @@ -1410,7 +1395,6 @@ mod tests { paused: false, current_session_id: None, process_start_time: None, - execution_mode: Some("background".to_string()), // Default for test }; // Create the mock provider instance for the test diff --git a/crates/goose/src/scheduler_factory.rs b/crates/goose/src/scheduler_factory.rs index d044c280cb9b..92c698dbd9e8 100644 --- a/crates/goose/src/scheduler_factory.rs +++ b/crates/goose/src/scheduler_factory.rs @@ -15,33 +15,41 @@ impl SchedulerType { pub fn from_config() -> Self { let config = Config::global(); - // Debug logging to help troubleshoot environment variable issues - tracing::debug!("Checking scheduler configuration..."); + // First check if alpha features are enabled + // If not, always use legacy scheduler regardless of GOOSE_SCHEDULER_TYPE + match config.get_param::("ALPHA") { + Ok(alpha_value) => { + // Only proceed with temporal if alpha is explicitly enabled + if alpha_value.to_lowercase() != "true" { + tracing::info!("Alpha features disabled, using legacy scheduler"); + return SchedulerType::Legacy; + } + } + Err(_) => { + // No ALPHA env var means alpha features are disabled + tracing::info!("No ALPHA environment variable found, using legacy scheduler"); + return SchedulerType::Legacy; + } + } - // Check scheduler type preference from GOOSE_SCHEDULER_TYPE + // Alpha is enabled, now check scheduler type preference match config.get_param::("GOOSE_SCHEDULER_TYPE") { - Ok(scheduler_type) => { - tracing::debug!( - "Found GOOSE_SCHEDULER_TYPE environment variable: '{}'", - scheduler_type - ); - match scheduler_type.to_lowercase().as_str() { - "temporal" => SchedulerType::Temporal, - "legacy" => SchedulerType::Legacy, - _ => { - tracing::warn!( - "Unknown scheduler type '{}', defaulting to legacy scheduler", - scheduler_type - ); - SchedulerType::Legacy - } + Ok(scheduler_type) => match scheduler_type.to_lowercase().as_str() { + "temporal" => SchedulerType::Temporal, + "legacy" => SchedulerType::Legacy, + _ => { + tracing::warn!( + "Unknown scheduler type '{}', defaulting to legacy scheduler", + scheduler_type + ); + SchedulerType::Legacy } - } + }, Err(_) => { - tracing::debug!("GOOSE_SCHEDULER_TYPE environment variable not found"); - // When no explicit scheduler type is set, default to legacy scheduler - tracing::info!("No scheduler type specified, defaulting to legacy scheduler"); - SchedulerType::Legacy + // When alpha is enabled but no explicit scheduler type is set, + // default to temporal scheduler + tracing::info!("Alpha enabled, defaulting to temporal scheduler"); + SchedulerType::Temporal } } } @@ -115,38 +123,62 @@ mod tests { use temp_env::with_vars; #[test] - fn test_scheduler_type_no_env() { - // Test that without GOOSE_SCHEDULER_TYPE env var, we get Legacy scheduler - with_vars([("GOOSE_SCHEDULER_TYPE", None::<&str>)], || { - let scheduler_type = SchedulerType::from_config(); - assert!(matches!(scheduler_type, SchedulerType::Legacy)); - }); + fn test_scheduler_type_no_alpha_env() { + // Test that without ALPHA env var, we always get Legacy scheduler + with_vars( + [ + ("ALPHA", None::<&str>), + ("GOOSE_SCHEDULER_TYPE", Some("temporal")), + ], + || { + let scheduler_type = SchedulerType::from_config(); + assert!(matches!(scheduler_type, SchedulerType::Legacy)); + }, + ); } #[test] - fn test_scheduler_type_legacy() { - // Test that with GOOSE_SCHEDULER_TYPE=legacy, we get Legacy scheduler - with_vars([("GOOSE_SCHEDULER_TYPE", Some("legacy"))], || { - let scheduler_type = SchedulerType::from_config(); - assert!(matches!(scheduler_type, SchedulerType::Legacy)); - }); + fn test_scheduler_type_alpha_false() { + // Test that with ALPHA=false, we always get Legacy scheduler + with_vars( + [ + ("ALPHA", Some("false")), + ("GOOSE_SCHEDULER_TYPE", Some("temporal")), + ], + || { + let scheduler_type = SchedulerType::from_config(); + assert!(matches!(scheduler_type, SchedulerType::Legacy)); + }, + ); } #[test] - fn test_scheduler_type_temporal() { - // Test that with GOOSE_SCHEDULER_TYPE=temporal, we get Temporal scheduler - with_vars([("GOOSE_SCHEDULER_TYPE", Some("temporal"))], || { - let scheduler_type = SchedulerType::from_config(); - assert!(matches!(scheduler_type, SchedulerType::Temporal)); - }); + fn test_scheduler_type_alpha_true_legacy() { + // Test that with ALPHA=true and GOOSE_SCHEDULER_TYPE=legacy, we get Legacy scheduler + with_vars( + [ + ("ALPHA", Some("true")), + ("GOOSE_SCHEDULER_TYPE", Some("legacy")), + ], + || { + let scheduler_type = SchedulerType::from_config(); + assert!(matches!(scheduler_type, SchedulerType::Legacy)); + }, + ); } #[test] - fn test_scheduler_type_unknown() { - // Test that with unknown scheduler type, we default to Legacy - with_vars([("GOOSE_SCHEDULER_TYPE", Some("unknown"))], || { - let scheduler_type = SchedulerType::from_config(); - assert!(matches!(scheduler_type, SchedulerType::Legacy)); - }); + fn test_scheduler_type_alpha_true_unknown_scheduler_type() { + // Test that with ALPHA=true and unknown scheduler type, we default to Legacy + with_vars( + [ + ("ALPHA", Some("true")), + ("GOOSE_SCHEDULER_TYPE", Some("unknown")), + ], + || { + let scheduler_type = SchedulerType::from_config(); + assert!(matches!(scheduler_type, SchedulerType::Legacy)); + }, + ); } } diff --git a/crates/goose/src/session/mod.rs b/crates/goose/src/session/mod.rs index 99b6bb08d54f..51d8957db18e 100644 --- a/crates/goose/src/session/mod.rs +++ b/crates/goose/src/session/mod.rs @@ -3,10 +3,9 @@ pub mod storage; // Re-export common session types and functions pub use storage::{ - ensure_session_dir, generate_description, generate_description_with_schedule_id, - generate_session_id, get_most_recent_session, get_path, list_sessions, persist_messages, - persist_messages_with_schedule_id, read_messages, read_metadata, update_metadata, Identifier, - SessionMetadata, + ensure_session_dir, generate_description, generate_session_id, get_most_recent_session, + get_path, list_sessions, persist_messages, read_messages, read_metadata, update_metadata, + Identifier, SessionMetadata, }; pub use info::{get_session_info, SessionInfo}; diff --git a/crates/goose/src/session/storage.rs b/crates/goose/src/session/storage.rs index 01c23da4e4fd..08298673dedd 100644 --- a/crates/goose/src/session/storage.rs +++ b/crates/goose/src/session/storage.rs @@ -1,28 +1,15 @@ -// IMPORTANT: This file includes session recovery functionality to handle corrupted session files. -// Only essential logging is included with the [SESSION] prefix to track: -// - Total message counts -// - Corruption detection and recovery -// - Backup creation -// Additional debug logging can be added if needed for troubleshooting. - use crate::message::Message; use crate::providers::base::Provider; use anyhow::Result; use chrono::Local; use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; -use regex::Regex; use serde::{Deserialize, Serialize}; -use std::fs; +use std::fs::{self, File}; use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; use utoipa::ToSchema; -// Security limits -const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB -const MAX_MESSAGE_COUNT: usize = 5000; -const MAX_LINE_LENGTH: usize = 1024 * 1024; // 1MB per line - fn get_home_dir() -> PathBuf { choose_app_strategy(crate::config::APP_STRATEGY.clone()) .expect("goose requires a home dir") @@ -138,169 +125,13 @@ pub enum Identifier { Path(PathBuf), } -pub fn get_path(id: Identifier) -> Result { - let path = match id { +pub fn get_path(id: Identifier) -> PathBuf { + match id { Identifier::Name(name) => { - // Validate session name for security - if name.is_empty() || name.len() > 255 { - return Err(anyhow::anyhow!("Invalid session name length")); - } - - // Check for path traversal attempts - if name.contains("..") || name.contains('/') || name.contains('\\') { - return Err(anyhow::anyhow!("Invalid characters in session name")); - } - - let session_dir = ensure_session_dir().map_err(|e| { - tracing::error!("Failed to create session directory: {}", e); - anyhow::anyhow!("Failed to access session directory") - })?; + let session_dir = ensure_session_dir().expect("Failed to create session directory"); session_dir.join(format!("{}.jsonl", name)) } - Identifier::Path(path) => { - // In test mode, allow temporary directory paths - #[cfg(test)] - { - if let Some(path_str) = path.to_str() { - if path_str.contains("/tmp") || path_str.contains("/.tmp") { - // Allow test temporary directories - return Ok(path); - } - } - } - - // Validate that the path is within allowed directories - let session_dir = ensure_session_dir().map_err(|e| { - tracing::error!("Failed to create session directory: {}", e); - anyhow::anyhow!("Failed to access session directory") - })?; - - // Handle path validation with Windows-compatible logic - let is_path_allowed = validate_path_within_session_dir(&path, &session_dir)?; - if !is_path_allowed { - tracing::warn!( - "Attempted access outside session directory: {:?} not within {:?}", - path, - session_dir - ); - return Err(anyhow::anyhow!("Path not allowed")); - } - - path - } - }; - - // Additional security check for file extension - if let Some(ext) = path.extension() { - if ext != "jsonl" { - return Err(anyhow::anyhow!("Invalid file extension")); - } - } - - Ok(path) -} - -/// Validate that a path is within the session directory, with Windows-compatible logic -/// -/// This function handles Windows-specific path issues like: -/// - UNC path conversion during canonicalization -/// - Case sensitivity differences -/// - Path separator normalization -/// - Drive letter casing inconsistencies -fn validate_path_within_session_dir(path: &Path, session_dir: &Path) -> Result { - // First, try the simple case - if canonicalization works cleanly - if let (Ok(canonical_path), Ok(canonical_session_dir)) = - (path.canonicalize(), session_dir.canonicalize()) - { - if canonical_path.starts_with(&canonical_session_dir) { - return Ok(true); - } - } - - // Fallback approach for Windows: normalize paths manually - let normalized_path = normalize_path_for_comparison(path); - let normalized_session_dir = normalize_path_for_comparison(session_dir); - - // Check if the normalized path starts with the normalized session directory - if normalized_path.starts_with(&normalized_session_dir) { - return Ok(true); - } - - // Additional check: if the path doesn't exist yet, check its parent directory - if !path.exists() { - if let Some(parent) = path.parent() { - return validate_path_within_session_dir(parent, session_dir); - } - } - - Ok(false) -} - -/// Normalize a path for cross-platform comparison -/// -/// This handles Windows-specific issues like: -/// - Converting to absolute paths -/// - Normalizing path separators -/// - Handling case sensitivity -fn normalize_path_for_comparison(path: &Path) -> PathBuf { - // Try to canonicalize first, but fall back to absolute path if that fails - let absolute_path = if let Ok(canonical) = path.canonicalize() { - canonical - } else if let Ok(absolute) = path.to_path_buf().canonicalize() { - absolute - } else { - // Last resort: try to make it absolute manually - if path.is_absolute() { - path.to_path_buf() - } else { - // If we can't make it absolute, use the current directory - std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(path) - } - }; - - // On Windows, normalize the path representation - #[cfg(windows)] - { - // Convert the path to components and rebuild it normalized - let components: Vec<_> = absolute_path.components().collect(); - let mut normalized = PathBuf::new(); - - for component in components { - match component { - std::path::Component::Prefix(prefix) => { - // Handle drive letters and UNC paths - let prefix_str = prefix.as_os_str().to_string_lossy(); - if prefix_str.starts_with("\\\\?\\") { - // Remove UNC prefix and add the drive letter normally - let clean_prefix = &prefix_str[4..]; - normalized.push(clean_prefix); - } else { - normalized.push(component); - } - } - std::path::Component::RootDir => { - normalized.push(component); - } - std::path::Component::CurDir | std::path::Component::ParentDir => { - // Skip these as they should be resolved by canonicalization - continue; - } - std::path::Component::Normal(name) => { - // Normalize case for Windows - let name_str = name.to_string_lossy().to_lowercase(); - normalized.push(name_str); - } - } - } - - normalized - } - - #[cfg(not(windows))] - { - absolute_path + Identifier::Path(path) => path, } } @@ -376,73 +207,24 @@ pub fn generate_session_id() -> String { Local::now().format("%Y%m%d_%H%M%S").to_string() } -/// Read messages from a session file with corruption recovery +/// Read messages from a session file /// /// Creates the file if it doesn't exist, reads and deserializes all messages if it does. /// The first line of the file is expected to be metadata, and the rest are messages. /// Large messages are automatically truncated to prevent memory issues. -/// Includes recovery mechanisms for corrupted files. -/// -/// Security features: -/// - Validates file paths to prevent directory traversal -/// - Includes all security limits from read_messages_with_truncation pub fn read_messages(session_file: &Path) -> Result> { - // Validate the path for security - let secure_path = get_path(Identifier::Path(session_file.to_path_buf()))?; - - let result = read_messages_with_truncation(&secure_path, Some(50000)); // 50KB limit per message content - match &result { - Ok(_messages) => {} - Err(e) => println!( - "[SESSION] Failed to read messages from {:?}: {}", - secure_path, e - ), - } - result + read_messages_with_truncation(session_file, Some(50000)) // 50KB limit per message content } -/// Read messages from a session file with optional content truncation and corruption recovery +/// Read messages from a session file with optional content truncation /// /// Creates the file if it doesn't exist, reads and deserializes all messages if it does. /// The first line of the file is expected to be metadata, and the rest are messages. /// If max_content_size is Some, large message content will be truncated during loading. -/// Includes robust error handling and corruption recovery mechanisms. -/// -/// Security features: -/// - File size limits to prevent resource exhaustion -/// - Message count limits to prevent DoS attacks -/// - Line length restrictions to prevent memory issues pub fn read_messages_with_truncation( session_file: &Path, max_content_size: Option, ) -> Result> { - // Security check: file size limit - if session_file.exists() { - let metadata = fs::metadata(session_file)?; - if metadata.len() > MAX_FILE_SIZE { - tracing::warn!("Session file exceeds size limit: {} bytes", metadata.len()); - return Err(anyhow::anyhow!("Session file too large")); - } - } - - // Check if there's a backup file we should restore from - let backup_file = session_file.with_extension("backup"); - if !session_file.exists() && backup_file.exists() { - println!( - "[SESSION] Session file missing but backup exists, restoring from backup: {:?}", - backup_file - ); - tracing::warn!( - "Session file missing but backup exists, restoring from backup: {:?}", - backup_file - ); - if let Err(e) = fs::copy(&backup_file, session_file) { - println!("[SESSION] Failed to restore from backup: {}", e); - tracing::error!("Failed to restore from backup: {}", e); - } - } - - // Open the file with appropriate options let file = fs::OpenOptions::new() .read(true) .write(true) @@ -453,164 +235,25 @@ pub fn read_messages_with_truncation( let reader = io::BufReader::new(file); let mut lines = reader.lines(); let mut messages = Vec::new(); - let mut corrupted_lines = Vec::new(); - let mut line_number = 1; - let mut message_count = 0; // Read the first line as metadata or create default if empty/missing - if let Some(line_result) = lines.next() { - match line_result { - Ok(line) => { - // Security check: line length - if line.len() > MAX_LINE_LENGTH { - tracing::warn!("Line {} exceeds length limit", line_number); - return Err(anyhow::anyhow!("Line too long")); - } - - // Try to parse as metadata, but if it fails, treat it as a message - if let Ok(_metadata) = serde_json::from_str::(&line) { - // Metadata successfully parsed, continue with the rest of the lines as messages - } else { - // This is not metadata, it's a message - match parse_message_with_truncation(&line, max_content_size) { - Ok(message) => { - messages.push(message); - message_count += 1; - } - Err(e) => { - println!("[SESSION] Failed to parse first line as message: {}", e); - println!("[SESSION] Attempting to recover corrupted first line..."); - tracing::warn!("Failed to parse first line as message: {}", e); - - // Try to recover the corrupted line - match attempt_corruption_recovery(&line, max_content_size) { - Ok(recovered) => { - println!( - "[SESSION] Successfully recovered corrupted first line!" - ); - messages.push(recovered); - message_count += 1; - } - Err(recovery_err) => { - println!( - "[SESSION] Failed to recover corrupted first line: {}", - recovery_err - ); - corrupted_lines.push((line_number, line)); - } - } - } - } - } - } - Err(e) => { - println!("[SESSION] Failed to read first line: {}", e); - tracing::error!("Failed to read first line: {}", e); - corrupted_lines.push((line_number, "[Unreadable line]".to_string())); - } + if let Some(line) = lines.next() { + let line = line?; + // Try to parse as metadata, but if it fails, treat it as a message + if let Ok(_metadata) = serde_json::from_str::(&line) { + // Metadata successfully parsed, continue with the rest of the lines as messages + } else { + // This is not metadata, it's a message + let message = parse_message_with_truncation(&line, max_content_size)?; + messages.push(message); } - line_number += 1; } // Read the rest of the lines as messages - for line_result in lines { - // Security check: message count limit - if message_count >= MAX_MESSAGE_COUNT { - tracing::warn!("Message count limit reached: {}", MAX_MESSAGE_COUNT); - println!( - "[SESSION] Message count limit reached, stopping at {}", - MAX_MESSAGE_COUNT - ); - break; - } - - match line_result { - Ok(line) => { - // Security check: line length - if line.len() > MAX_LINE_LENGTH { - tracing::warn!("Line {} exceeds length limit", line_number); - corrupted_lines.push(( - line_number, - "[Line too long - truncated for security]".to_string(), - )); - line_number += 1; - continue; - } - - match parse_message_with_truncation(&line, max_content_size) { - Ok(message) => { - messages.push(message); - message_count += 1; - } - Err(e) => { - println!("[SESSION] Failed to parse line {}: {}", line_number, e); - println!( - "[SESSION] Attempting to recover corrupted line {}...", - line_number - ); - tracing::warn!("Failed to parse line {}: {}", line_number, e); - - // Try to recover the corrupted line - match attempt_corruption_recovery(&line, max_content_size) { - Ok(recovered) => { - println!( - "[SESSION] Successfully recovered corrupted line {}!", - line_number - ); - messages.push(recovered); - message_count += 1; - } - Err(recovery_err) => { - println!( - "[SESSION] Failed to recover corrupted line {}: {}", - line_number, recovery_err - ); - corrupted_lines.push((line_number, line)); - } - } - } - } - } - Err(e) => { - println!("[SESSION] Failed to read line {}: {}", line_number, e); - tracing::error!("Failed to read line {}: {}", line_number, e); - corrupted_lines.push((line_number, "[Unreadable line]".to_string())); - } - } - line_number += 1; - } - - // If we found corrupted lines, create a backup and log the issues - if !corrupted_lines.is_empty() { - println!( - "[SESSION] Found {} corrupted lines, creating backup", - corrupted_lines.len() - ); - tracing::warn!( - "Found {} corrupted lines in session file, creating backup", - corrupted_lines.len() - ); - - // Create a backup of the original file - if !backup_file.exists() { - if let Err(e) = fs::copy(session_file, &backup_file) { - println!("[SESSION] Failed to create backup file: {}", e); - tracing::error!("Failed to create backup file: {}", e); - } else { - println!("[SESSION] Created backup file: {:?}", backup_file); - tracing::info!("Created backup file: {:?}", backup_file); - } - } - - // Log details about corrupted lines (with limited detail for security) - for (num, line) in &corrupted_lines { - let preview = if line.len() > 50 { - format!("{}... (truncated)", &line[..50]) - } else { - line.clone() - }; - tracing::debug!("Corrupted line {}: {}", num, preview); - } + for line in lines { + let line = line?; + let message = parse_message_with_truncation(&line, max_content_size)?; + messages.push(message); } Ok(messages) @@ -630,13 +273,9 @@ fn parse_message_with_truncation( } Ok(message) } - Err(_e) => { + Err(e) => { // If parsing fails and the string is very long, it might be due to size if json_str.len() > 100000 { - println!( - "[SESSION] Very large message detected ({}KB), attempting truncation", - json_str.len() / 1024 - ); tracing::warn!( "Failed to parse very large message ({}KB), attempting truncation", json_str.len() / 1024 @@ -655,16 +294,14 @@ fn parse_message_with_truncation( Ok(message) } Err(_) => { - println!( - "[SESSION] Failed to parse even after truncation, attempting recovery" - ); - tracing::error!("Failed to parse message even after truncation"); - attempt_corruption_recovery(json_str, max_content_size) + tracing::error!("Failed to parse message even after truncation, skipping"); + // Return a placeholder message indicating the issue + Ok(Message::user() + .with_text("[Message too large to load - content truncated]")) } } } else { - // Try intelligent corruption recovery - attempt_corruption_recovery(json_str, max_content_size) + Err(e.into()) } } } @@ -728,222 +365,6 @@ fn truncate_message_content_in_place(message: &mut Message, max_content_size: us } } -/// Attempt to recover corrupted JSON lines using various strategies -fn attempt_corruption_recovery(json_str: &str, max_content_size: Option) -> Result { - // Strategy 1: Try to fix common JSON corruption issues - if let Ok(message) = try_fix_json_corruption(json_str, max_content_size) { - println!("[SESSION] Recovered using JSON corruption fix"); - return Ok(message); - } - - // Strategy 2: Try to extract partial content if it looks like a message - if let Ok(message) = try_extract_partial_message(json_str) { - println!("[SESSION] Recovered using partial message extraction"); - return Ok(message); - } - - // Strategy 3: Try to fix truncated JSON - if let Ok(message) = try_fix_truncated_json(json_str, max_content_size) { - println!("[SESSION] Recovered using truncated JSON fix"); - return Ok(message); - } - - // Strategy 4: Create a placeholder message with the raw content - println!("[SESSION] All recovery strategies failed, creating placeholder message"); - let preview = if json_str.len() > 200 { - format!("{}...", &json_str[..200]) - } else { - json_str.to_string() - }; - - Ok(Message::user().with_text(format!( - "[RECOVERED FROM CORRUPTED LINE]\nOriginal content preview: {}\n\n[This message was recovered from a corrupted session file line. The original data may be incomplete.]", - preview - ))) -} - -/// Try to fix common JSON corruption patterns -fn try_fix_json_corruption(json_str: &str, max_content_size: Option) -> Result { - let mut fixed_json = json_str.to_string(); - let mut fixes_applied = Vec::new(); - - // Fix 1: Remove trailing commas before closing braces/brackets - if fixed_json.contains(",}") || fixed_json.contains(",]") { - fixed_json = fixed_json.replace(",}", "}").replace(",]", "]"); - fixes_applied.push("trailing commas"); - } - - // Fix 2: Try to close unclosed quotes in text fields - if let Some(text_start) = fixed_json.find("\"text\":\"") { - let content_start = text_start + 8; - if let Some(remaining) = fixed_json.get(content_start..) { - // Count quotes to see if we have an odd number (unclosed quote) - let quote_count = remaining.matches('"').count(); - if quote_count % 2 == 1 { - // Find the last quote and see if we need to close it - if let Some(last_quote_pos) = remaining.rfind('"') { - let after_last_quote = &remaining[last_quote_pos + 1..]; - if !after_last_quote.trim_start().starts_with(',') - && !after_last_quote.trim_start().starts_with('}') - { - // Insert a closing quote before the next field or end - if let Some(next_field) = after_last_quote.find(',') { - fixed_json.insert(content_start + last_quote_pos + 1 + next_field, '"'); - fixes_applied.push("unclosed quotes"); - } else if after_last_quote.contains('}') { - if let Some(brace_pos) = after_last_quote.find('}') { - fixed_json - .insert(content_start + last_quote_pos + 1 + brace_pos, '"'); - fixes_applied.push("unclosed quotes"); - } - } - } - } - } - } - } - - // Fix 3: Try to close unclosed JSON objects/arrays - let open_braces = fixed_json.matches('{').count(); - let close_braces = fixed_json.matches('}').count(); - let open_brackets = fixed_json.matches('[').count(); - let close_brackets = fixed_json.matches(']').count(); - - if open_braces > close_braces { - for _ in 0..(open_braces - close_braces) { - fixed_json.push('}'); - } - fixes_applied.push("unclosed braces"); - } - - if open_brackets > close_brackets { - for _ in 0..(open_brackets - close_brackets) { - fixed_json.push(']'); - } - fixes_applied.push("unclosed brackets"); - } - - // Fix 4: Remove control characters that might break JSON parsing - let original_len = fixed_json.len(); - fixed_json = fixed_json - .chars() - .filter(|c| !c.is_control() || *c == '\n' || *c == '\r' || *c == '\t') - .collect(); - if fixed_json.len() != original_len { - fixes_applied.push("control characters"); - } - - if !fixes_applied.is_empty() { - match serde_json::from_str::(&fixed_json) { - Ok(mut message) => { - if let Some(max_size) = max_content_size { - truncate_message_content_in_place(&mut message, max_size); - } - return Ok(message); - } - Err(e) => { - println!("[SESSION] JSON fixes didn't work: {}", e); - } - } - } - - Err(anyhow::anyhow!("JSON corruption fixes failed")) -} - -/// Try to extract a partial message from corrupted JSON -fn try_extract_partial_message(json_str: &str) -> Result { - // Look for recognizable patterns that indicate this was a message - - // Try to extract role - let role = if json_str.contains("\"role\":\"user\"") { - mcp_core::role::Role::User - } else if json_str.contains("\"role\":\"assistant\"") { - mcp_core::role::Role::Assistant - } else { - mcp_core::role::Role::User // Default fallback - }; - - // Try to extract text content - let mut extracted_text = String::new(); - - // Look for text field content - if let Some(text_start) = json_str.find("\"text\":\"") { - let content_start = text_start + 8; - if let Some(content_end) = json_str[content_start..].find("\",") { - extracted_text = json_str[content_start..content_start + content_end].to_string(); - } else if let Some(content_end) = json_str[content_start..].find("\"") { - extracted_text = json_str[content_start..content_start + content_end].to_string(); - } else { - // Take everything after "text":" until we hit a likely end - let remaining = &json_str[content_start..]; - if let Some(end_pos) = remaining.find('}') { - extracted_text = remaining[..end_pos].trim_end_matches('"').to_string(); - } else { - extracted_text = remaining.to_string(); - } - } - } - - // If we couldn't extract text, try to find any readable content - if extracted_text.is_empty() { - // Look for any quoted strings that might be content - let quote_pattern = Regex::new(r#""([^"]{10,})""#).unwrap(); - if let Some(captures) = quote_pattern.find(json_str) { - extracted_text = captures.as_str().trim_matches('"').to_string(); - } - } - - if !extracted_text.is_empty() { - let message = match role { - mcp_core::role::Role::User => Message::user(), - mcp_core::role::Role::Assistant => Message::assistant(), - }; - - return Ok(message.with_text(format!("[PARTIALLY RECOVERED] {}", extracted_text))); - } - - Err(anyhow::anyhow!("Could not extract partial message")) -} - -/// Try to fix truncated JSON by completing it -fn try_fix_truncated_json(json_str: &str, max_content_size: Option) -> Result { - let mut completed_json = json_str.to_string(); - - // If the JSON appears to be cut off mid-field, try to complete it - if !completed_json.trim().ends_with('}') && !completed_json.trim().ends_with(']') { - // Try to find where it was likely cut off - if let Some(last_quote) = completed_json.rfind('"') { - let after_quote = &completed_json[last_quote + 1..]; - if !after_quote.contains('"') && !after_quote.contains('}') { - // Looks like it was cut off in the middle of a string value - completed_json.push('"'); - - // Try to close the JSON structure - let open_braces = completed_json.matches('{').count(); - let close_braces = completed_json.matches('}').count(); - - for _ in 0..(open_braces - close_braces) { - completed_json.push('}'); - } - - match serde_json::from_str::(&completed_json) { - Ok(mut message) => { - if let Some(max_size) = max_content_size { - truncate_message_content_in_place(&mut message, max_size); - } - return Ok(message); - } - Err(e) => { - println!("[SESSION] Truncation fix didn't work: {}", e); - } - } - } - } - } - - Err(anyhow::anyhow!("Truncation fix failed")) -} - /// Attempt to truncate a JSON string by finding and truncating large text values fn truncate_json_string(json_str: &str, max_content_size: usize) -> String { // This is a heuristic approach - look for large text values in the JSON @@ -980,46 +401,25 @@ fn truncate_json_string(json_str: &str, max_content_size: usize) -> String { result } -/// Read session metadata from a session file with security validation +/// Read session metadata from a session file /// /// Returns default empty metadata if the file doesn't exist or has no metadata. -/// Includes security checks for file access and content validation. pub fn read_metadata(session_file: &Path) -> Result { - // Validate the path for security - let secure_path = get_path(Identifier::Path(session_file.to_path_buf()))?; - - if !secure_path.exists() { + if !session_file.exists() { return Ok(SessionMetadata::default()); } - // Security check: file size - let file_metadata = fs::metadata(&secure_path)?; - if file_metadata.len() > MAX_FILE_SIZE { - tracing::warn!("Session file exceeds size limit during metadata read"); - return Err(anyhow::anyhow!("Session file too large")); - } - - let file = fs::File::open(&secure_path).map_err(|e| { - tracing::error!("Failed to open session file for metadata read: {}", e); - anyhow::anyhow!("Failed to access session file") - })?; + let file = fs::File::open(session_file)?; let mut reader = io::BufReader::new(file); let mut first_line = String::new(); // Read just the first line if reader.read_line(&mut first_line)? > 0 { - // Security check: line length - if first_line.len() > MAX_LINE_LENGTH { - tracing::warn!("Metadata line exceeds length limit"); - return Err(anyhow::anyhow!("Metadata line too long")); - } - // Try to parse as metadata match serde_json::from_str::(&first_line) { Ok(metadata) => Ok(metadata), - Err(e) => { + Err(_) => { // If the first line isn't metadata, return default - tracing::debug!("Metadata parse error: {}", e); Ok(SessionMetadata::default()) } } @@ -1033,42 +433,11 @@ pub fn read_metadata(session_file: &Path) -> Result { /// /// Overwrites the file with metadata as the first line, followed by all messages in JSONL format. /// If a provider is supplied, it will automatically generate a description when appropriate. -/// -/// Security features: -/// - Validates file paths to prevent directory traversal -/// - Uses secure file operations via persist_messages_with_schedule_id pub async fn persist_messages( session_file: &Path, messages: &[Message], provider: Option>, ) -> Result<()> { - persist_messages_with_schedule_id(session_file, messages, provider, None).await -} - -/// Write messages to a session file with metadata, including an optional scheduled job ID -/// -/// Overwrites the file with metadata as the first line, followed by all messages in JSONL format. -/// If a provider is supplied, it will automatically generate a description when appropriate. -/// -/// Security features: -/// - Validates file paths to prevent directory traversal -/// - Limits error message details in logs -/// - Uses atomic file operations via save_messages_with_metadata -pub async fn persist_messages_with_schedule_id( - session_file: &Path, - messages: &[Message], - provider: Option>, - schedule_id: Option, -) -> Result<()> { - // Validate the session file path for security - let secure_path = get_path(Identifier::Path(session_file.to_path_buf()))?; - - // Security check: message count limit - if messages.len() > MAX_MESSAGE_COUNT { - tracing::warn!("Message count exceeds limit: {}", messages.len()); - return Err(anyhow::anyhow!("Too many messages")); - } - // Count user messages let user_message_count = messages .iter() @@ -1079,142 +448,39 @@ pub async fn persist_messages_with_schedule_id( match provider { Some(provider) if user_message_count < 4 => { //generate_description is responsible for writing the messages - generate_description_with_schedule_id(&secure_path, messages, provider, schedule_id) - .await + generate_description(session_file, messages, provider).await } _ => { // Read existing metadata - let mut metadata = read_metadata(&secure_path)?; - // Update the schedule_id if provided - if schedule_id.is_some() { - metadata.schedule_id = schedule_id; - } + let metadata = read_metadata(session_file)?; // Write the file with metadata and messages - save_messages_with_metadata(&secure_path, &metadata, messages) + save_messages_with_metadata(session_file, &metadata, messages) } } } -/// Write messages to a session file with the provided metadata using secure atomic operations +/// Write messages to a session file with the provided metadata /// -/// This function uses atomic file operations to prevent corruption: -/// 1. Writes to a temporary file first with secure permissions -/// 2. Uses fs2 file locking to prevent concurrent writes -/// 3. Atomically moves the temp file to the final location -/// 4. Includes comprehensive error handling and recovery -/// -/// Security features: -/// - Secure temporary file creation with restricted permissions -/// - Path validation to prevent directory traversal -/// - File size and message count limits -/// - Sanitized error messages to prevent information leakage +/// Overwrites the file with metadata as the first line, followed by all messages in JSONL format. pub fn save_messages_with_metadata( session_file: &Path, metadata: &SessionMetadata, messages: &[Message], ) -> Result<()> { - use fs2::FileExt; - - // Validate the path for security - let secure_path = get_path(Identifier::Path(session_file.to_path_buf()))?; - - // Security check: message count limit - if messages.len() > MAX_MESSAGE_COUNT { - tracing::warn!( - "Message count exceeds limit during save: {}", - messages.len() - ); - return Err(anyhow::anyhow!("Too many messages to save")); - } - - // Create a temporary file in the same directory to ensure atomic move - let temp_file = secure_path.with_extension("tmp"); - - // Ensure the parent directory exists - if let Some(parent) = secure_path.parent() { - fs::create_dir_all(parent).map_err(|e| { - tracing::error!("Failed to create parent directory: {}", e); - anyhow::anyhow!("Failed to create session directory") - })?; - } + let file = File::create(session_file).expect("The path specified does not exist"); + let mut writer = io::BufWriter::new(file); - // Create and lock the temporary file with secure permissions - let file = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&temp_file) - .map_err(|e| { - tracing::error!("Failed to create temporary file: {}", e); - anyhow::anyhow!("Failed to create temporary session file") - })?; - - // Set secure file permissions (Unix only - read/write for owner only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = file.metadata()?.permissions(); - perms.set_mode(0o600); // rw------- - fs::set_permissions(&temp_file, perms).map_err(|e| { - tracing::error!("Failed to set secure file permissions: {}", e); - anyhow::anyhow!("Failed to secure temporary file") - })?; - } - - // Get an exclusive lock on the file - file.try_lock_exclusive().map_err(|e| { - tracing::error!("Failed to lock file: {}", e); - anyhow::anyhow!("Failed to lock session file") - })?; - - // Write to temporary file - { - let mut writer = io::BufWriter::new(&file); + // Write metadata as the first line + serde_json::to_writer(&mut writer, &metadata)?; + writeln!(writer)?; - // Write metadata as the first line - serde_json::to_writer(&mut writer, &metadata).map_err(|e| { - tracing::error!("Failed to serialize metadata: {}", e); - anyhow::anyhow!("Failed to write session metadata") - })?; + // Write all messages + for message in messages { + serde_json::to_writer(&mut writer, &message)?; writeln!(writer)?; - - // Write all messages with progress tracking - for (i, message) in messages.iter().enumerate() { - serde_json::to_writer(&mut writer, &message).map_err(|e| { - tracing::error!("Failed to serialize message {}: {}", i, e); - anyhow::anyhow!("Failed to write session message") - })?; - writeln!(writer)?; - } - - // Ensure all data is written to disk - writer.flush().map_err(|e| { - tracing::error!("Failed to flush writer: {}", e); - anyhow::anyhow!("Failed to flush session data") - })?; } - // Sync to ensure data is persisted - file.sync_all().map_err(|e| { - tracing::error!("Failed to sync data: {}", e); - anyhow::anyhow!("Failed to sync session data") - })?; - - // Release the lock - fs2::FileExt::unlock(&file).map_err(|e| { - tracing::error!("Failed to unlock file: {}", e); - anyhow::anyhow!("Failed to unlock session file") - })?; - - // Atomically move the temporary file to the final location - fs::rename(&temp_file, &secure_path).map_err(|e| { - // Clean up temp file on failure - tracing::error!("Failed to move temporary file: {}", e); - let _ = fs::remove_file(&temp_file); - anyhow::anyhow!("Failed to finalize session file") - })?; - - tracing::debug!("Successfully saved session file: {:?}", secure_path); + writer.flush()?; Ok(()) } @@ -1227,54 +493,15 @@ pub async fn generate_description( messages: &[Message], provider: Arc, ) -> Result<()> { - generate_description_with_schedule_id(session_file, messages, provider, None).await -} - -/// Generate a description for the session using the provider, including an optional scheduled job ID -/// -/// This function is called when appropriate to generate a short description -/// of the session based on the conversation history. -/// -/// Security features: -/// - Validates file paths to prevent directory traversal -/// - Limits context size to prevent resource exhaustion -/// - Uses secure file operations for saving -pub async fn generate_description_with_schedule_id( - session_file: &Path, - messages: &[Message], - provider: Arc, - schedule_id: Option, -) -> Result<()> { - // Validate the path for security - let secure_path = get_path(Identifier::Path(session_file.to_path_buf()))?; - - // Security check: message count limit - if messages.len() > MAX_MESSAGE_COUNT { - tracing::warn!( - "Message count exceeds limit during description generation: {}", - messages.len() - ); - return Err(anyhow::anyhow!( - "Too many messages for description generation" - )); - } - // Create a special message asking for a 3-word description let mut description_prompt = "Based on the conversation so far, provide a concise description of this session in 4 words or less. This will be used for finding the session later in a UI with limited space - reply *ONLY* with the description".to_string(); - // get context from messages so far, limiting each message to 300 chars for security + // get context from messages so far, limiting each message to 300 chars let context: Vec = messages .iter() .filter(|m| m.role == mcp_core::role::Role::User) .take(3) // Use up to first 3 user messages for context - .map(|m| { - let text = m.as_concat_text(); - if text.len() > 300 { - format!("{}...", &text[..300]) - } else { - text - } - }) + .map(|m| m.as_concat_text()) .collect(); if !context.is_empty() { @@ -1285,7 +512,7 @@ pub async fn generate_description_with_schedule_id( ); } - // Generate the description with error handling + // Generate the description let message = Message::user().with_text(&description_prompt); let result = provider .complete( @@ -1293,49 +520,27 @@ pub async fn generate_description_with_schedule_id( &[message], &[], ) - .await - .map_err(|e| { - tracing::error!("Failed to generate session description: {}", e); - anyhow::anyhow!("Failed to generate session description") - })?; + .await?; let description = result.0.as_concat_text(); - // Validate description length for security - let sanitized_description = if description.len() > 100 { - tracing::warn!("Generated description too long, truncating"); - format!("{}...", &description[..97]) - } else { - description - }; - // Read current metadata - let mut metadata = read_metadata(&secure_path)?; + let mut metadata = read_metadata(session_file)?; - // Update description and schedule_id - metadata.description = sanitized_description; - if schedule_id.is_some() { - metadata.schedule_id = schedule_id; - } + // Update description + metadata.description = description; // Update the file with the new metadata and existing messages - save_messages_with_metadata(&secure_path, &metadata, messages) + save_messages_with_metadata(session_file, &metadata, messages) } /// Update only the metadata in a session file, preserving all messages -/// -/// Security features: -/// - Validates file paths to prevent directory traversal -/// - Uses secure file operations for reading and writing pub async fn update_metadata(session_file: &Path, metadata: &SessionMetadata) -> Result<()> { - // Validate the path for security - let secure_path = get_path(Identifier::Path(session_file.to_path_buf()))?; - // Read all messages from the file - let messages = read_messages(&secure_path)?; + let messages = read_messages(session_file)?; // Rewrite the file with the new metadata and existing messages - save_messages_with_metadata(&secure_path, metadata, &messages) + save_messages_with_metadata(session_file, metadata, &messages) } #[cfg(test)] @@ -1344,79 +549,6 @@ mod tests { use crate::message::MessageContent; use tempfile::tempdir; - #[test] - fn test_corruption_recovery() -> Result<()> { - let test_cases = vec![ - // Case 1: Unclosed quotes - ( - r#"{"role":"user","content":[{"type":"text","text":"Hello there}]"#, - "Unclosed JSON with truncated content", - ), - // Case 2: Trailing comma - ( - r#"{"role":"user","content":[{"type":"text","text":"Test"},]}"#, - "JSON with trailing comma", - ), - // Case 3: Missing closing brace - ( - r#"{"role":"user","content":[{"type":"text","text":"Test""#, - "Incomplete JSON structure", - ), - // Case 4: Control characters in text - ( - r#"{"role":"user","content":[{"type":"text","text":"Test\u{0000}with\u{0001}control\u{0002}chars"}]}"#, - "JSON with control characters", - ), - // Case 5: Partial message with role and text - ( - r#"broken{"role": "assistant", "text": "This is recoverable content"more broken"#, - "Partial message with recoverable content", - ), - ]; - - println!("[TEST] Starting corruption recovery tests..."); - for (i, (corrupt_json, desc)) in test_cases.iter().enumerate() { - println!("\n[TEST] Case {}: {}", i + 1, desc); - println!( - "[TEST] Input: {}", - if corrupt_json.len() > 100 { - &corrupt_json[..100] - } else { - corrupt_json - } - ); - - // Try to parse the corrupted JSON - match attempt_corruption_recovery(corrupt_json, Some(50000)) { - Ok(message) => { - println!("[TEST] Successfully recovered message"); - // Verify we got some content - if let Some(MessageContent::Text(text_content)) = message.content.first() { - assert!( - !text_content.text.is_empty(), - "Recovered message should have content" - ); - println!( - "[TEST] Recovered content: {}", - if text_content.text.len() > 50 { - format!("{}...", &text_content.text[..50]) - } else { - text_content.text.clone() - } - ); - } - } - Err(e) => { - println!("[TEST] Failed to recover: {}", e); - panic!("Failed to recover from case {}: {}", i + 1, desc); - } - } - } - - println!("\n[TEST] All corruption recovery tests passed!"); - Ok(()) - } - #[tokio::test] async fn test_read_write_messages() -> Result<()> { let dir = tempdir()?; @@ -1692,55 +824,4 @@ mod tests { Ok(()) } - - #[test] - fn test_windows_path_validation() -> Result<()> { - // Test the Windows path validation logic - let temp_dir = tempfile::tempdir()?; - let session_dir = temp_dir.path().join("sessions"); - fs::create_dir_all(&session_dir)?; - - // Test case 1: Valid path within session directory - let valid_path = session_dir.join("test.jsonl"); - assert!(validate_path_within_session_dir(&valid_path, &session_dir)?); - - // Test case 2: Invalid path outside session directory - let invalid_path = temp_dir.path().join("outside.jsonl"); - assert!(!validate_path_within_session_dir( - &invalid_path, - &session_dir - )?); - - // Test case 3: Path with different separators (simulate Windows issue) - let mixed_sep_path = session_dir.join("subdir").join("test.jsonl"); - fs::create_dir_all(mixed_sep_path.parent().unwrap())?; - assert!(validate_path_within_session_dir( - &mixed_sep_path, - &session_dir - )?); - - // Test case 4: Non-existent path within session directory - let nonexistent_path = session_dir.join("nonexistent").join("test.jsonl"); - assert!(validate_path_within_session_dir( - &nonexistent_path, - &session_dir - )?); - - Ok(()) - } - - #[test] - fn test_path_normalization() { - let temp_dir = tempfile::tempdir().unwrap(); - let test_path = temp_dir.path().join("test"); - - // Test that normalization doesn't crash and returns a path - let normalized = normalize_path_for_comparison(&test_path); - assert!(!normalized.as_os_str().is_empty()); - - // Test with existing path - fs::create_dir_all(&test_path).unwrap(); - let normalized_existing = normalize_path_for_comparison(&test_path); - assert!(!normalized_existing.as_os_str().is_empty()); - } } diff --git a/crates/goose/src/temporal_scheduler.rs b/crates/goose/src/temporal_scheduler.rs index 8cc3bd5474e4..a2d54b9736e2 100644 --- a/crates/goose/src/temporal_scheduler.rs +++ b/crates/goose/src/temporal_scheduler.rs @@ -9,16 +9,15 @@ use serde::{Deserialize, Serialize}; use tokio::time::sleep; use tracing::{info, warn}; -use crate::scheduler::{normalize_cron_expression, ScheduledJob, SchedulerError}; +use crate::scheduler::{ScheduledJob, SchedulerError}; use crate::scheduler_trait::SchedulerTrait; use crate::session::storage::SessionMetadata; const TEMPORAL_SERVICE_STARTUP_TIMEOUT: Duration = Duration::from_secs(15); const TEMPORAL_SERVICE_HEALTH_CHECK_INTERVAL: Duration = Duration::from_millis(500); -// Default ports to try when discovering the service - using high, obscure ports -// to avoid conflicts with common services -const DEFAULT_HTTP_PORTS: &[u16] = &[58080, 58081, 58082, 58083, 58084, 58085]; +// Default ports to try when discovering the service +const DEFAULT_HTTP_PORTS: &[u16] = &[8080, 8081, 8082, 8083, 8084, 8085]; #[derive(Serialize, Deserialize, Debug)] struct JobRequest { @@ -26,7 +25,6 @@ struct JobRequest { job_id: Option, cron: Option, recipe_path: Option, - execution_mode: Option, } #[derive(Serialize, Deserialize, Debug)] @@ -47,7 +45,6 @@ struct TemporalJobStatus { currently_running: bool, paused: bool, created_at: String, - execution_mode: Option, } #[derive(Serialize, Deserialize, Debug)] @@ -55,14 +52,13 @@ struct RunNowResponse { session_id: String, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug)] pub struct PortConfig { http_port: u16, temporal_port: u16, ui_port: u16, } -#[derive(Clone)] pub struct TemporalScheduler { http_client: Client, service_url: String, @@ -111,61 +107,50 @@ impl TemporalScheduler { port_config, }); - // Start the status monitor to keep job statuses in sync - if let Err(e) = final_scheduler.start_status_monitor().await { - tracing::warn!("Failed to start status monitor: {}", e); - } - info!("TemporalScheduler initialized successfully"); Ok(final_scheduler) } - async fn discover_http_port(http_client: &Client) -> Result { - info!("Discovering Temporal service port..."); + async fn discover_http_port(_http_client: &Client) -> Result { + // First, try to find a running service using pgrep and lsof + if let Ok(port) = Self::find_temporal_service_port_from_processes() { + info!( + "Found Temporal service port {} from running processes", + port + ); + return Ok(port); + } + + // If no running service found, we need to find a free port to start the service on + info!("No running Temporal service found, finding free port to start service"); // Check PORT environment variable first if let Ok(port_str) = std::env::var("PORT") { if let Ok(port) = port_str.parse::() { - if Self::is_temporal_service_running(http_client, port).await { - info!( - "Found running Temporal service on PORT environment variable: {}", - port - ); - return Ok(port); - } else if Self::is_port_free(port).await { - info!("Using PORT environment variable for new service: {}", port); + if Self::is_port_free(port).await { + info!("Using PORT environment variable: {}", port); return Ok(port); } else { warn!( - "PORT environment variable {} is occupied by non-Temporal service", + "PORT environment variable {} is not free, finding alternative", port ); } } } - // Try to find an existing Temporal service on default ports - for &port in DEFAULT_HTTP_PORTS { - if Self::is_temporal_service_running(http_client, port).await { - info!("Found existing Temporal service on port {}", port); - return Ok(port); - } - } - - // If no existing service found, find a free port to start a new one - info!("No existing Temporal service found, finding free port to start new service"); - + // Try to find a free port from the default list for &port in DEFAULT_HTTP_PORTS { if Self::is_port_free(port).await { - info!("Found free port {} for new Temporal service", port); + info!("Found free port {} for Temporal service", port); return Ok(port); } } // If all default ports are taken, find any free port in a reasonable range - for port in 58086..58200 { + for port in 8086..8200 { if Self::is_port_free(port).await { - info!("Found free port {} for new Temporal service", port); + info!("Found free port {} for Temporal service", port); return Ok(port); } } @@ -175,51 +160,112 @@ impl TemporalScheduler { )) } - /// Check if a Temporal service is running and responding on the given port - async fn is_temporal_service_running(http_client: &Client, port: u16) -> bool { - let health_url = format!("http://127.0.0.1:{}/health", port); + async fn is_port_free(port: u16) -> bool { + use std::net::{SocketAddr, TcpListener}; + use std::time::Duration; - match http_client - .get(&health_url) - .timeout(Duration::from_millis(1000)) - .send() - .await - { - Ok(response) if response.status().is_success() => { - info!("Confirmed Temporal service is running on port {}", port); - true - } - Ok(response) => { - info!( - "Port {} is responding but not a healthy Temporal service (status: {})", - port, - response.status() - ); - false + let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap(); + + // First, try to bind to the port + let listener_result = TcpListener::bind(addr); + match listener_result { + Ok(listener) => { + // Successfully bound, so port was free + drop(listener); // Release the port immediately + + // Double-check by trying to connect to see if anything is actually listening + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .build() + .unwrap(); + + let test_url = format!("http://127.0.0.1:{}", port); + match client.get(&test_url).send().await { + Ok(_) => { + // Something responded, so port is actually in use + warn!( + "Port {} appeared free but something is listening on it", + port + ); + false + } + Err(_) => { + // Nothing responded, port is truly free + true + } + } } Err(_) => { - // Port might be free or occupied by something else + // Could not bind, port is definitely in use false } } } - async fn is_port_free(port: u16) -> bool { - use std::net::{SocketAddr, TcpListener}; + fn find_temporal_service_port_from_processes() -> Result { + // Use pgrep to find temporal-service processes + let pgrep_output = Command::new("pgrep") + .arg("-f") + .arg("temporal-service") + .output() + .map_err(|e| SchedulerError::SchedulerInternalError(format!("pgrep failed: {}", e)))?; - let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap(); + if !pgrep_output.status.success() { + return Err(SchedulerError::SchedulerInternalError( + "No temporal-service processes found".to_string(), + )); + } - // Try to bind to the port - match TcpListener::bind(addr) { - Ok(_listener) => { - // Successfully bound, so port is free - true - } - Err(_) => { - // Could not bind, port is in use - false + let pids_str = String::from_utf8_lossy(&pgrep_output.stdout); + let pids: Vec<&str> = pids_str + .trim() + .split('\n') + .filter(|s| !s.is_empty()) + .collect(); + + for pid in pids { + // Use lsof to find listening ports for this PID + let lsof_output = Command::new("lsof") + .arg("-p") + .arg(pid) + .arg("-i") + .arg("tcp") + .arg("-P") // Show port numbers instead of service names + .arg("-n") // Show IP addresses instead of hostnames + .output(); + + if let Ok(output) = lsof_output { + let lsof_str = String::from_utf8_lossy(&output.stdout); + + // Look for HTTP API port (typically 8080-8999 range) + for line in lsof_str.lines() { + if line.contains("LISTEN") && line.contains("temporal-") { + // Parse lines like: "temporal-service 12345 user 6u IPv4 0x... 0t0 TCP *:8081 (LISTEN)" + let parts: Vec<&str> = line.split_whitespace().collect(); + + // Find the TCP part which contains the port + for part in &parts { + if part.starts_with("TCP") && part.contains(':') { + // Extract port from TCP *:8081 or TCP 127.0.0.1:8081 + if let Some(port_str) = part.split(':').next_back() { + if let Ok(port) = port_str.parse::() { + // HTTP API ports are typically in 8080-8999 range + if (8080..9000).contains(&port) { + info!("Found HTTP API port {} for PID {}", port, pid); + return Ok(port); + } + } + } + } + } + } + } } } + + Err(SchedulerError::SchedulerInternalError( + "Could not find HTTP API port from temporal-service processes".to_string(), + )) } async fn fetch_port_config(&self) -> Result { @@ -259,7 +305,7 @@ impl TemporalScheduler { self.port_config.temporal_port } - /// Get the HTTP API port + /// Get the HTTP API port pub fn get_http_port(&self) -> u16 { self.port_config.http_port } @@ -320,7 +366,7 @@ impl TemporalScheduler { command.process_group(0); } - let mut child = command.spawn().map_err(|e| { + let child = command.spawn().map_err(|e| { SchedulerError::SchedulerInternalError(format!( "Failed to start Go temporal service: {}", e @@ -333,6 +379,9 @@ impl TemporalScheduler { pid, self.port_config.http_port ); + // Don't wait for the child process - let it run independently + std::mem::forget(child); + // Give the process a moment to start up sleep(Duration::from_millis(100)).await; @@ -361,12 +410,6 @@ impl TemporalScheduler { } } - // Detach the child process by not waiting for it - // This allows it to continue running independently - std::thread::spawn(move || { - let _ = child.wait(); - }); - Ok(()) } @@ -487,23 +530,11 @@ impl TemporalScheduler { "TemporalScheduler: add_scheduled_job() called for job '{}'", job.id ); - - // Normalize the cron expression to ensure it's 6-field format - let normalized_cron = normalize_cron_expression(&job.cron); - if normalized_cron != job.cron { - tracing::info!( - "TemporalScheduler: Normalized cron expression from '{}' to '{}'", - job.cron, - normalized_cron - ); - } - let request = JobRequest { action: "create".to_string(), job_id: Some(job.id.clone()), - cron: Some(normalized_cron.clone()), + cron: Some(job.cron.clone()), recipe_path: Some(job.source.clone()), - execution_mode: job.execution_mode.clone(), }; let response = self.make_request(request).await?; @@ -523,7 +554,6 @@ impl TemporalScheduler { job_id: None, cron: None, recipe_path: None, - execution_mode: None, }; let response = self.make_request(request).await?; @@ -542,7 +572,6 @@ impl TemporalScheduler { paused: tj.paused, current_session_id: None, // Not provided by Temporal service process_start_time: None, // Not provided by Temporal service - execution_mode: tj.execution_mode, } }) .collect(); @@ -558,7 +587,6 @@ impl TemporalScheduler { job_id: Some(id.to_string()), cron: None, recipe_path: None, - execution_mode: None, }; let response = self.make_request(request).await?; @@ -577,7 +605,6 @@ impl TemporalScheduler { job_id: Some(id.to_string()), cron: None, recipe_path: None, - execution_mode: None, }; let response = self.make_request(request).await?; @@ -596,7 +623,6 @@ impl TemporalScheduler { job_id: Some(id.to_string()), cron: None, recipe_path: None, - execution_mode: None, }; let response = self.make_request(request).await?; @@ -616,7 +642,6 @@ impl TemporalScheduler { job_id: Some(id.to_string()), cron: None, recipe_path: None, - execution_mode: None, }; let response = self.make_request(request).await?; @@ -692,192 +717,20 @@ impl TemporalScheduler { pub async fn update_schedule( &self, - sched_id: &str, - new_cron: String, + _sched_id: &str, + _new_cron: String, ) -> Result<(), SchedulerError> { - tracing::info!( - "TemporalScheduler: update_schedule() called for job '{}' with cron '{}'", - sched_id, - new_cron - ); - - // Normalize the cron expression to ensure it's 6-field format - let normalized_cron = normalize_cron_expression(&new_cron); - if normalized_cron != new_cron { - tracing::info!( - "TemporalScheduler: Normalized cron expression from '{}' to '{}'", - new_cron, - normalized_cron - ); - } - - let request = JobRequest { - action: "update".to_string(), - job_id: Some(sched_id.to_string()), - cron: Some(normalized_cron), - recipe_path: None, - execution_mode: None, - }; - - let response = self.make_request(request).await?; - - if response.success { - info!("Successfully updated scheduled job: {}", sched_id); - Ok(()) - } else { - Err(SchedulerError::SchedulerInternalError(response.message)) - } - } - - pub async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError> { - tracing::info!( - "TemporalScheduler: kill_running_job() called for job '{}'", - sched_id - ); - - let request = JobRequest { - action: "kill_job".to_string(), - job_id: Some(sched_id.to_string()), - cron: None, - recipe_path: None, - execution_mode: None, - }; - - let response = self.make_request(request).await?; - - if response.success { - info!("Successfully killed running job: {}", sched_id); - Ok(()) - } else { - Err(SchedulerError::SchedulerInternalError(response.message)) - } - } - - pub async fn update_job_status_from_sessions(&self) -> Result<(), SchedulerError> { - tracing::info!("TemporalScheduler: Checking job status based on session activity"); - - let jobs = self.list_scheduled_jobs().await?; - - for job in jobs { - if job.currently_running { - // First, check with the Temporal service directly for the most accurate status - let request = JobRequest { - action: "status".to_string(), - job_id: Some(job.id.clone()), - cron: None, - recipe_path: None, - execution_mode: None, - }; - - match self.make_request(request).await { - Ok(response) => { - if response.success { - if let Some(jobs) = response.jobs { - if let Some(temporal_job) = jobs.iter().find(|j| j.id == job.id) { - // If Temporal service says it's not running, trust that - if !temporal_job.currently_running { - tracing::info!( - "Temporal service reports job '{}' is not running", - job.id - ); - continue; // Job is already marked as not running by Temporal - } - } - } - } - } - Err(e) => { - tracing::warn!( - "Failed to get status from Temporal service for job '{}': {}", - job.id, - e - ); - // Fall back to session-based checking if Temporal service is unavailable - } - } - - // Secondary check: look for recent session activity (more lenient timing) - let recent_sessions = self.sessions(&job.id, 3).await?; - let mut has_active_session = false; - - for (session_name, _) in recent_sessions { - let session_path = match crate::session::storage::get_path( - crate::session::storage::Identifier::Name(session_name.clone()), - ) { - Ok(path) => path, - Err(e) => { - tracing::warn!( - "Failed to get session path for '{}': {}", - session_name, - e - ); - continue; - } - }; - - // Check if session file was modified recently (within last 5 minutes instead of 2) - if let Ok(metadata) = std::fs::metadata(&session_path) { - if let Ok(modified) = metadata.modified() { - let modified_dt: DateTime = modified.into(); - let now = Utc::now(); - let time_diff = now.signed_duration_since(modified_dt); - - // Increased tolerance to 5 minutes to reduce false positives - if time_diff.num_minutes() < 5 { - has_active_session = true; - tracing::debug!( - "Found active session for job '{}' modified {} minutes ago", - job.id, - time_diff.num_minutes() - ); - break; - } - } - } - } - - // Only mark as completed if both Temporal service check failed AND no recent session activity - if !has_active_session { - tracing::info!( - "No active sessions found for job '{}' in the last 5 minutes, marking as completed", - job.id - ); - - let request = JobRequest { - action: "mark_completed".to_string(), - job_id: Some(job.id.clone()), - cron: None, - recipe_path: None, - execution_mode: None, - }; - - if let Err(e) = self.make_request(request).await { - tracing::warn!("Failed to mark job '{}' as completed: {}", job.id, e); - } - } - } - } - - Ok(()) + warn!("update_schedule() method not implemented for TemporalScheduler - delete and recreate job instead"); + Err(SchedulerError::SchedulerInternalError( + "update_schedule not supported - delete and recreate job instead".to_string(), + )) } - /// Periodically check and update job statuses based on session activity - pub async fn start_status_monitor(&self) -> Result<(), SchedulerError> { - let scheduler_clone = self.clone(); - - tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(60)); // Check every 60 seconds instead of 30 - - loop { - interval.tick().await; - - if let Err(e) = scheduler_clone.update_job_status_from_sessions().await { - tracing::warn!("Failed to update job statuses: {}", e); - } - } - }); - - Ok(()) + pub async fn kill_running_job(&self, _sched_id: &str) -> Result<(), SchedulerError> { + warn!("kill_running_job() method not implemented for TemporalScheduler"); + Err(SchedulerError::SchedulerInternalError( + "kill_running_job not supported by TemporalScheduler".to_string(), + )) } pub async fn get_running_job_info( @@ -889,76 +742,24 @@ impl TemporalScheduler { sched_id ); - // Get the current job status from Temporal service - let request = JobRequest { - action: "status".to_string(), - job_id: Some(sched_id.to_string()), - cron: None, - recipe_path: None, - execution_mode: None, - }; - - let response = self.make_request(request).await?; - - if response.success { - if let Some(jobs) = response.jobs { - if let Some(job) = jobs.iter().find(|j| j.id == sched_id) { - if job.currently_running { - // Try to get the actual session ID from recent sessions - let recent_sessions = self.sessions(sched_id, 1).await?; - - if let Some((session_name, _session_metadata)) = recent_sessions.first() { - // Check if this session is still active by looking at the session file - let session_path = match crate::session::storage::get_path( - crate::session::storage::Identifier::Name(session_name.clone()), - ) { - Ok(path) => path, - Err(e) => { - tracing::warn!( - "Failed to get session path for '{}': {}", - session_name, - e - ); - // Fallback: return a temporal session ID with current time - let session_id = - format!("temporal-{}-{}", sched_id, Utc::now().timestamp()); - let start_time = Utc::now(); - return Ok(Some((session_id, start_time))); - } - }; - - // If the session file was modified recently (within last 5 minutes), - // consider it as the current running session - if let Ok(metadata) = std::fs::metadata(&session_path) { - if let Ok(modified) = metadata.modified() { - let modified_dt: DateTime = modified.into(); - let now = Utc::now(); - let time_diff = now.signed_duration_since(modified_dt); - - if time_diff.num_minutes() < 5 { - // This looks like an active session - return Ok(Some((session_name.clone(), modified_dt))); - } - } - } - } + // First check if the job is marked as currently running + let jobs = self.list_scheduled_jobs().await?; + let job = jobs.iter().find(|j| j.id == sched_id); - // Fallback: return a temporal session ID with current time - let session_id = - format!("temporal-{}-{}", sched_id, Utc::now().timestamp()); - let start_time = Utc::now(); - Ok(Some((session_id, start_time))) - } else { - Ok(None) - } - } else { - Err(SchedulerError::JobNotFound(sched_id.to_string())) - } + if let Some(job) = job { + if job.currently_running { + // For now, we'll return a placeholder session ID and current time + // In a more complete implementation, we would track the actual session ID + // and start time from the Temporal workflow execution + let session_id = + format!("temporal-{}-{}", sched_id, chrono::Utc::now().timestamp()); + let start_time = chrono::Utc::now(); // This should be the actual start time + Ok(Some((session_id, start_time))) } else { - Err(SchedulerError::JobNotFound(sched_id.to_string())) + Ok(None) } } else { - Err(SchedulerError::SchedulerInternalError(response.message)) + Err(SchedulerError::JobNotFound(sched_id.to_string())) } } @@ -1220,43 +1021,17 @@ mod tests { } #[test] - fn test_job_status_detection_improvements() { - // Test that the new job status detection methods compile and work correctly - use tokio::runtime::Runtime; - - let rt = Runtime::new().unwrap(); - rt.block_on(async { - // This test verifies the improved job status detection compiles - match TemporalScheduler::new().await { - Ok(scheduler) => { - // Test the new status update method - match scheduler.update_job_status_from_sessions().await { - Ok(()) => { - println!("✅ update_job_status_from_sessions() works correctly"); - } - Err(e) => { - println!("⚠️ update_job_status_from_sessions() returned error (expected if no jobs): {}", e); - } - } + fn test_sessions_method_signature() { + // This test verifies the method signature is correct at compile time + // We just need to verify the method exists and can be called + + // This will fail to compile if the method doesn't exist or has wrong signature + let _test_fn = |scheduler: &TemporalScheduler, id: &str, limit: usize| { + // This is a compile-time check - we don't actually call it + let _future = scheduler.sessions(id, limit); + }; - // Test the improved get_running_job_info method - match scheduler.get_running_job_info("test-job").await { - Ok(None) => { - println!("✅ get_running_job_info() correctly returns None for non-existent job"); - } - Ok(Some((session_id, start_time))) => { - println!("✅ get_running_job_info() returned session info: {} at {}", session_id, start_time); - } - Err(e) => { - println!("⚠️ get_running_job_info() returned error (expected): {}", e); - } - } - } - Err(e) => { - println!("⚠️ Temporal services not running - method signature test passed: {}", e); - } - } - }); + println!("✅ sessions() method signature is correct"); } #[test] @@ -1329,24 +1104,4 @@ mod tests { } } } - - #[test] - fn test_cron_normalization_in_temporal_scheduler() { - // Test that the temporal scheduler uses cron normalization correctly - use crate::scheduler::normalize_cron_expression; - - // Test cases that should be normalized - assert_eq!(normalize_cron_expression("0 12 * * *"), "0 0 12 * * * *"); - assert_eq!(normalize_cron_expression("*/5 * * * *"), "0 */5 * * * * *"); - assert_eq!(normalize_cron_expression("0 0 * * 1"), "0 0 0 * * 1 *"); - - // Test cases that should remain unchanged - assert_eq!(normalize_cron_expression("0 0 12 * * *"), "0 0 12 * * * *"); - assert_eq!( - normalize_cron_expression("*/30 */5 * * * *"), - "*/30 */5 * * * * *" - ); - - println!("✅ Cron normalization works correctly in TemporalScheduler"); - } } diff --git a/crates/goose/tests/test_support.rs b/crates/goose/tests/test_support.rs index cfea855b1085..cec7cdb2b55b 100644 --- a/crates/goose/tests/test_support.rs +++ b/crates/goose/tests/test_support.rs @@ -361,7 +361,6 @@ impl ScheduleToolTestBuilder { paused: false, current_session_id: None, process_start_time: None, - execution_mode: Some("background".to_string()), }; { let mut jobs = self.scheduler.jobs.lock().await; diff --git a/temporal-service/build.sh b/temporal-service/build.sh index 13baa5382f28..bb3f98c6f2a3 100755 --- a/temporal-service/build.sh +++ b/temporal-service/build.sh @@ -14,40 +14,22 @@ if [ ! -f "go.sum" ]; then go mod tidy fi -# Determine binary name based on target OS -BINARY_NAME="temporal-service" -if [ "${GOOS:-}" = "windows" ]; then - BINARY_NAME="temporal-service.exe" -fi - -# Build the service with cross-compilation support +# Build the service echo "Compiling Go binary..." -if [ -n "${GOOS:-}" ] && [ -n "${GOARCH:-}" ]; then - echo "Cross-compiling for ${GOOS}/${GOARCH}..." - GOOS="${GOOS}" GOARCH="${GOARCH}" go build -buildvcs=false -o "${BINARY_NAME}" . -else - echo "Building for current platform..." - go build -buildvcs=false -o "${BINARY_NAME}" . -fi +go build -o temporal-service main.go -# Make it executable (skip on Windows as it's not needed) -if [ "${GOOS:-}" != "windows" ]; then - chmod +x "${BINARY_NAME}" -fi +# Make it executable +chmod +x temporal-service echo "Build completed successfully!" -echo "Binary location: $(pwd)/${BINARY_NAME}" - -# Only show usage info if not cross-compiling -if [ -z "${GOOS:-}" ] || [ "${GOOS}" = "$(go env GOOS)" ]; then - echo "" - echo "Prerequisites:" - echo " 1. Install Temporal CLI: brew install temporal" - echo " 2. Start Temporal server: temporal server start-dev" - echo "" - echo "To run the service:" - echo " ./${BINARY_NAME}" - echo "" - echo "Environment variables:" - echo " PORT - HTTP port (default: 8080)" -fi \ No newline at end of file +echo "Binary location: $(pwd)/temporal-service" +echo "" +echo "Prerequisites:" +echo " 1. Install Temporal CLI: brew install temporal" +echo " 2. Start Temporal server: temporal server start-dev" +echo "" +echo "To run the service:" +echo " ./temporal-service" +echo "" +echo "Environment variables:" +echo " PORT - HTTP port (default: 8080)" \ No newline at end of file diff --git a/temporal-service/main.go b/temporal-service/main.go index fcc1dc39cc57..a17dddc70943 100644 --- a/temporal-service/main.go +++ b/temporal-service/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "log" "net" @@ -10,15 +11,17 @@ import ( "os/exec" "os/signal" "path/filepath" - "runtime" "strconv" "strings" - "sync" "syscall" "time" "go.temporal.io/api/workflowservice/v1" + "go.temporal.io/sdk/activity" "go.temporal.io/sdk/client" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/worker" + "go.temporal.io/sdk/workflow" ) const ( @@ -33,48 +36,6 @@ type PortConfig struct { HTTPPort int // HTTP API port } -// getManagedRecipesDir returns the proper directory for storing managed recipes -func getManagedRecipesDir() (string, error) { - var baseDir string - - switch runtime.GOOS { - case "darwin": - // macOS: ~/Library/Application Support/temporal/managed-recipes - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get user home directory: %w", err) - } - baseDir = filepath.Join(homeDir, "Library", "Application Support", "temporal", "managed-recipes") - case "linux": - // Linux: ~/.local/share/temporal/managed-recipes - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get user home directory: %w", err) - } - baseDir = filepath.Join(homeDir, ".local", "share", "temporal", "managed-recipes") - case "windows": - // Windows: %APPDATA%\temporal\managed-recipes - appDataDir := os.Getenv("APPDATA") - if appDataDir == "" { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get user home directory: %w", err) - } - appDataDir = filepath.Join(homeDir, "AppData", "Roaming") - } - baseDir = filepath.Join(appDataDir, "temporal", "managed-recipes") - default: - // Fallback for unknown OS - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get user home directory: %w", err) - } - baseDir = filepath.Join(homeDir, ".local", "share", "temporal", "managed-recipes") - } - - return baseDir, nil -} - // findAvailablePort finds an available port starting from the given port func findAvailablePort(startPort int) (int, error) { for port := startPort; port < startPort+100; port++ { @@ -94,12 +55,12 @@ func findAvailablePorts() (*PortConfig, error) { if err != nil { return nil, fmt.Errorf("failed to find available port for Temporal server: %w", err) } - + uiPort, err := findAvailablePort(8233) if err != nil { return nil, fmt.Errorf("failed to find available port for Temporal UI: %w", err) } - + // For HTTP port, check environment variable first httpPort := 8080 if portEnv := os.Getenv("PORT"); portEnv != "" { @@ -107,13 +68,13 @@ func findAvailablePorts() (*PortConfig, error) { httpPort = parsed } } - + // Verify HTTP port is available, find alternative if not finalHTTPPort, err := findAvailablePort(httpPort) if err != nil { return nil, fmt.Errorf("failed to find available port for HTTP server: %w", err) } - + return &PortConfig{ TemporalPort: temporalPort, UIPort: uiPort, @@ -121,6 +82,92 @@ func findAvailablePorts() (*PortConfig, error) { }, nil } +// Global service instance for activities to access +var globalService *TemporalService + +// Request/Response types for HTTP API +type JobRequest struct { + Action string `json:"action"` // create, delete, pause, unpause, list, run_now + JobID string `json:"job_id"` + CronExpr string `json:"cron"` + RecipePath string `json:"recipe_path"` +} + +type JobResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Jobs []JobStatus `json:"jobs,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +type JobStatus struct { + ID string `json:"id"` + CronExpr string `json:"cron"` + RecipePath string `json:"recipe_path"` + LastRun *string `json:"last_run,omitempty"` + NextRun *string `json:"next_run,omitempty"` + CurrentlyRunning bool `json:"currently_running"` + Paused bool `json:"paused"` + CreatedAt time.Time `json:"created_at"` +} + +type RunNowResponse struct { + SessionID string `json:"session_id"` +} + +// ensureTemporalServerRunning checks if Temporal server is running and starts it if needed +func ensureTemporalServerRunning(ports *PortConfig) error { + log.Println("Checking if Temporal server is running...") + + // Check if Temporal server is already running by trying to connect + if isTemporalServerRunning(ports.TemporalPort) { + log.Printf("Temporal server is already running on port %d", ports.TemporalPort) + return nil + } + + log.Printf("Temporal server not running, attempting to start it on port %d...", ports.TemporalPort) + + // Find the temporal CLI binary + temporalCmd, err := findTemporalCLI() + if err != nil { + return fmt.Errorf("could not find temporal CLI: %w", err) + } + + log.Printf("Using Temporal CLI at: %s", temporalCmd) + + // Start Temporal server in background + cmd := exec.Command(temporalCmd, "server", "start-dev", + "--db-filename", "temporal.db", + "--port", strconv.Itoa(ports.TemporalPort), + "--ui-port", strconv.Itoa(ports.UIPort), + "--log-level", "warn") + + // Start the process in background + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start Temporal server: %w", err) + } + + log.Printf("Temporal server started with PID: %d (port: %d, UI port: %d)", + cmd.Process.Pid, ports.TemporalPort, ports.UIPort) + + // Wait for server to be ready (with timeout) + timeout := time.After(30 * time.Second) + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-timeout: + return fmt.Errorf("timeout waiting for Temporal server to start") + case <-ticker.C: + if isTemporalServerRunning(ports.TemporalPort) { + log.Printf("Temporal server is now ready on port %d", ports.TemporalPort) + return nil + } + } + } +} + // isTemporalServerRunning checks if Temporal server is accessible func isTemporalServerRunning(port int) bool { // Try to create a client connection to check if server is running @@ -132,36 +179,26 @@ func isTemporalServerRunning(port int) bool { return false } defer c.Close() - + // Try a simple operation to verify the connection works ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - + _, err = c.WorkflowService().GetSystemInfo(ctx, &workflowservice.GetSystemInfoRequest{}) return err == nil } // findTemporalCLI attempts to find the temporal CLI binary func findTemporalCLI() (string, error) { - log.Println("Looking for temporal CLI binary...") - // First, try to find temporal in PATH using exec.LookPath - log.Println("Checking PATH for temporal CLI...") if path, err := exec.LookPath("temporal"); err == nil { - log.Printf("Found temporal in PATH at: %s", path) // Verify it's the correct temporal CLI by checking version - log.Println("Verifying temporal CLI version...") cmd := exec.Command(path, "--version") if err := cmd.Run(); err == nil { - log.Printf("Successfully verified temporal CLI at: %s", path) return path, nil - } else { - log.Printf("Failed to verify temporal CLI at %s: %v", path, err) } - } else { - log.Printf("temporal not found in PATH: %v", err) } - + // Try using 'which' command to find temporal cmd := exec.Command("which", "temporal") if output, err := cmd.Output(); err == nil { @@ -174,159 +211,512 @@ func findTemporalCLI() (string, error) { } } } - + // If not found in PATH, try different possible locations for the temporal CLI - log.Println("Checking bundled/local locations for temporal CLI...") - currentPaths := []string{ - "./temporal", - "./temporal.exe", + possiblePaths := []string{ + "./temporal", // Current directory + } + + // Also try relative to the current executable (most important for bundled apps) + if exePath, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exePath) + possiblePaths = append(possiblePaths, + filepath.Join(exeDir, "temporal"), + filepath.Join(exeDir, "temporal.exe"), // Windows + // Also try one level up (for development) + filepath.Join(exeDir, "..", "temporal"), + filepath.Join(exeDir, "..", "temporal.exe"), + ) + } + + // Test each possible path + for _, path := range possiblePaths { + if _, err := os.Stat(path); err == nil { + // File exists, test if it's executable and the right binary + cmd := exec.Command(path, "--version") + if err := cmd.Run(); err == nil { + return path, nil + } + } + } + + return "", fmt.Errorf("temporal CLI not found in PATH or any of the expected locations: %v", possiblePaths) +} + +// TemporalService manages the Temporal client and provides HTTP API +type TemporalService struct { + client client.Client + worker worker.Worker + scheduleJobs map[string]*JobStatus // In-memory job tracking + runningJobs map[string]bool // Track which jobs are currently running + ports *PortConfig // Port configuration +} + +// NewTemporalService creates a new Temporal service and ensures Temporal server is running +func NewTemporalService() (*TemporalService, error) { + // First, find available ports + ports, err := findAvailablePorts() + if err != nil { + return nil, fmt.Errorf("failed to find available ports: %w", err) } - if path, err := getExistingTemporalCLIFrom(currentPaths); err == nil { - return path, nil - } else { - log.Printf("Attempt to find in local directory failed: %s.", err) + + log.Printf("Using ports - Temporal: %d, UI: %d, HTTP: %d", + ports.TemporalPort, ports.UIPort, ports.HTTPPort) + + // Ensure Temporal server is running + if err := ensureTemporalServerRunning(ports); err != nil { + return nil, fmt.Errorf("failed to ensure Temporal server is running: %w", err) } - // Also try relative to the current executable (most important for bundled apps) - exePath, err := os.Executable() + // Create client (Temporal server should now be running) + c, err := client.Dial(client.Options{ + HostPort: fmt.Sprintf("127.0.0.1:%d", ports.TemporalPort), + Namespace: Namespace, + }) if err != nil { - log.Printf("Failed to get executable path: %v", err) - } - exeDir := filepath.Dir(exePath) - log.Printf("Executable directory: %s", exeDir) - additionalPaths := []string{ - filepath.Join(exeDir, "temporal"), - filepath.Join(exeDir, "temporal.exe"), // Windows - // Also try one level up (for development) - filepath.Join(exeDir, "..", "temporal"), - filepath.Join(exeDir, "..", "temporal.exe"), - } - log.Printf("Will check these additional paths: %v", additionalPaths) - return getExistingTemporalCLIFrom(additionalPaths) + return nil, fmt.Errorf("failed to create temporal client: %w", err) + } + + // Create worker + w := worker.New(c, TaskQueueName, worker.Options{}) + w.RegisterWorkflow(GooseJobWorkflow) + w.RegisterActivity(ExecuteGooseRecipe) + + if err := w.Start(); err != nil { + c.Close() + return nil, fmt.Errorf("failed to start worker: %w", err) + } + + log.Printf("Connected to Temporal server successfully on port %d", ports.TemporalPort) + + service := &TemporalService{ + client: c, + worker: w, + scheduleJobs: make(map[string]*JobStatus), + runningJobs: make(map[string]bool), + ports: ports, + } + + // Set global service for activities + globalService = service + + return service, nil } -// getExistingTemporalCLIFrom gets a list of paths and returns one of those that is an existing and working Temporal CLI binary -func getExistingTemporalCLIFrom(possiblePaths []string) (string, error) { - log.Printf("Checking %d possible paths for temporal CLI", len(possiblePaths)) - - // Check all possible paths in parallel, pick the first one that works. - pathFound := make(chan string) - var wg sync.WaitGroup - // This allows us to cancel whatever remaining work is done when we find a valid path. - psCtx, psCancel := context.WithCancel(context.Background()) - for i, path := range possiblePaths { - wg.Add(1) - go func() { - defer wg.Done() - log.Printf("Checking path %d/%d: %s", i+1, len(possiblePaths), path) - if _, err := os.Stat(path); err != nil { - log.Printf("File does not exist at %s: %v", path, err) - return - } - log.Printf("File exists at: %s", path) - // File exists, test if it's executable and the right binary - cmd := exec.CommandContext(psCtx, path, "--version") - if err := cmd.Run(); err != nil { - log.Printf("Failed to verify temporal CLI at %s: %v", path, err) - return - } - select { - case pathFound <- path: - log.Printf("Successfully verified temporal CLI at: %s", path) - case <-psCtx.Done(): - // No need to report the path not chosen. - } - }() +// Stop gracefully shuts down the Temporal service +func (ts *TemporalService) Stop() { + log.Println("Shutting down Temporal service...") + if ts.worker != nil { + ts.worker.Stop() } - // We transform the workgroup wait into a channel so we can wait for either this or pathFound - pathNotFound := make(chan bool) - go func() { - wg.Wait() - pathNotFound <- true - }() - select { - case path := <-pathFound: - psCancel() // Cancel the remaining search functions otherwise they'll just exist eternally. - return path, nil - case <-pathNotFound: - // No need to do anything, this just says that none of the functions were able to do it and there's nothing left to cleanup + if ts.client != nil { + ts.client.Close() } + log.Println("Temporal service stopped") +} - return "", fmt.Errorf("temporal CLI not found in PATH or any of the expected locations: %v", possiblePaths) +// GetHTTPPort returns the HTTP port for this service +func (ts *TemporalService) GetHTTPPort() int { + return ts.ports.HTTPPort } -// ensureTemporalServerRunning checks if Temporal server is running and starts it if needed -func ensureTemporalServerRunning(ports *PortConfig) error { - log.Println("Checking if Temporal server is running...") +// GetTemporalPort returns the Temporal server port for this service +func (ts *TemporalService) GetTemporalPort() int { + return ts.ports.TemporalPort +} - // Check if Temporal server is already running by trying to connect - if isTemporalServerRunning(ports.TemporalPort) { - log.Printf("Temporal server is already running on port %d", ports.TemporalPort) - return nil +// GetUIPort returns the Temporal UI port for this service +func (ts *TemporalService) GetUIPort() int { + return ts.ports.UIPort +} + +// Workflow definition for executing Goose recipes +func GooseJobWorkflow(ctx workflow.Context, jobID, recipePath string) (string, error) { + logger := workflow.GetLogger(ctx) + logger.Info("Starting Goose job workflow", "jobID", jobID, "recipePath", recipePath) + + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 2 * time.Hour, // Allow up to 2 hours for job execution + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: time.Second, + BackoffCoefficient: 2.0, + MaximumInterval: time.Minute, + MaximumAttempts: 3, + NonRetryableErrorTypes: []string{"InvalidRecipeError"}, + }, } + ctx = workflow.WithActivityOptions(ctx, ao) - log.Printf("Temporal server not running, attempting to start it on port %d...", ports.TemporalPort) + var sessionID string + err := workflow.ExecuteActivity(ctx, ExecuteGooseRecipe, jobID, recipePath).Get(ctx, &sessionID) + if err != nil { + logger.Error("Goose job workflow failed", "jobID", jobID, "error", err) + return "", err + } - // Find the temporal CLI binary - temporalCmd, err := findTemporalCLI() + logger.Info("Goose job workflow completed", "jobID", jobID, "sessionID", sessionID) + return sessionID, nil +} + +// Activity definition for executing Goose recipes +func ExecuteGooseRecipe(ctx context.Context, jobID, recipePath string) (string, error) { + logger := activity.GetLogger(ctx) + logger.Info("Executing Goose recipe", "jobID", jobID, "recipePath", recipePath) + + // Mark job as running at the start + if globalService != nil { + globalService.markJobAsRunning(jobID) + // Ensure we mark it as not running when we're done + defer globalService.markJobAsNotRunning(jobID) + } + + // Check if recipe file exists + if _, err := os.Stat(recipePath); os.IsNotExist(err) { + return "", temporal.NewNonRetryableApplicationError( + fmt.Sprintf("recipe file not found: %s", recipePath), + "InvalidRecipeError", + err, + ) + } + + // Execute the Goose recipe via the executor binary + cmd := exec.CommandContext(ctx, "goose-scheduler-executor", jobID, recipePath) + cmd.Env = append(os.Environ(), fmt.Sprintf("GOOSE_JOB_ID=%s", jobID)) + + output, err := cmd.Output() if err != nil { - log.Printf("ERROR: Could not find temporal CLI: %v", err) - return fmt.Errorf("could not find temporal CLI: %w", err) + if exitError, ok := err.(*exec.ExitError); ok { + logger.Error("Recipe execution failed", "jobID", jobID, "stderr", string(exitError.Stderr)) + return "", fmt.Errorf("recipe execution failed: %s", string(exitError.Stderr)) + } + return "", fmt.Errorf("failed to execute recipe: %w", err) } - log.Printf("Using Temporal CLI at: %s", temporalCmd) + sessionID := strings.TrimSpace(string(output)) + logger.Info("Recipe executed successfully", "jobID", jobID, "sessionID", sessionID) + return sessionID, nil +} - // Start Temporal server in background - args := []string{"server", "start-dev", - "--db-filename", "temporal.db", - "--port", strconv.Itoa(ports.TemporalPort), - "--ui-port", strconv.Itoa(ports.UIPort), - "--log-level", "warn"} +// HTTP API handlers - log.Printf("Starting Temporal server with command: %s %v", temporalCmd, args) +func (ts *TemporalService) handleJobs(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") - cmd := exec.Command(temporalCmd, args...) + if r.Method != http.MethodPost { + ts.writeErrorResponse(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + var req JobRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + ts.writeErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("Invalid JSON: %v", err)) + return + } - // Properly detach the process so it survives when the parent exits - configureSysProcAttr(cmd) + var resp JobResponse + + switch req.Action { + case "create": + resp = ts.createSchedule(req) + case "delete": + resp = ts.deleteSchedule(req) + case "pause": + resp = ts.pauseSchedule(req) + case "unpause": + resp = ts.unpauseSchedule(req) + case "list": + resp = ts.listSchedules() + case "run_now": + resp = ts.runNow(req) + default: + resp = JobResponse{Success: false, Message: fmt.Sprintf("Unknown action: %s", req.Action)} + } - // Redirect stdin/stdout/stderr to avoid hanging - cmd.Stdin = nil - cmd.Stdout = nil - cmd.Stderr = nil + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) +} - // Start the process - if err := cmd.Start(); err != nil { - log.Printf("ERROR: Failed to start Temporal server: %v", err) - return fmt.Errorf("failed to start Temporal server: %w", err) +func (ts *TemporalService) createSchedule(req JobRequest) JobResponse { + if req.JobID == "" || req.CronExpr == "" || req.RecipePath == "" { + return JobResponse{Success: false, Message: "Missing required fields: job_id, cron, recipe_path"} } - log.Printf("Temporal server started with PID: %d (port: %d, UI port: %d)", - cmd.Process.Pid, ports.TemporalPort, ports.UIPort) + // Check if job already exists + if _, exists := ts.scheduleJobs[req.JobID]; exists { + return JobResponse{Success: false, Message: fmt.Sprintf("Job with ID '%s' already exists", req.JobID)} + } - // Wait for server to be ready (with timeout) - log.Println("Waiting for Temporal server to be ready...") - timeout := time.After(30 * time.Second) - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() + // Validate recipe file exists + if _, err := os.Stat(req.RecipePath); os.IsNotExist(err) { + return JobResponse{Success: false, Message: fmt.Sprintf("Recipe file not found: %s", req.RecipePath)} + } - attemptCount := 0 - for { - select { - case <-timeout: - log.Printf("ERROR: Timeout waiting for Temporal server to start after %d attempts", attemptCount) - return fmt.Errorf("timeout waiting for Temporal server to start") - case <-ticker.C: - attemptCount++ - log.Printf("Checking if Temporal server is ready (attempt %d)...", attemptCount) - if isTemporalServerRunning(ports.TemporalPort) { - log.Printf("Temporal server is now ready on port %d", ports.TemporalPort) - return nil + scheduleID := fmt.Sprintf("goose-job-%s", req.JobID) + + // Create Temporal schedule + schedule := client.ScheduleOptions{ + ID: scheduleID, + Spec: client.ScheduleSpec{ + CronExpressions: []string{req.CronExpr}, + }, + Action: &client.ScheduleWorkflowAction{ + ID: fmt.Sprintf("workflow-%s-{{.ScheduledTime.Unix}}", req.JobID), + Workflow: GooseJobWorkflow, + Args: []interface{}{req.JobID, req.RecipePath}, + TaskQueue: TaskQueueName, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := ts.client.ScheduleClient().Create(ctx, schedule) + if err != nil { + return JobResponse{Success: false, Message: fmt.Sprintf("Failed to create schedule: %v", err)} + } + + // Track job in memory + jobStatus := &JobStatus{ + ID: req.JobID, + CronExpr: req.CronExpr, + RecipePath: req.RecipePath, + CurrentlyRunning: false, + Paused: false, + CreatedAt: time.Now(), + } + ts.scheduleJobs[req.JobID] = jobStatus + + log.Printf("Created schedule for job: %s", req.JobID) + return JobResponse{Success: true, Message: "Schedule created successfully"} +} + +func (ts *TemporalService) deleteSchedule(req JobRequest) JobResponse { + if req.JobID == "" { + return JobResponse{Success: false, Message: "Missing job_id"} + } + + scheduleID := fmt.Sprintf("goose-job-%s", req.JobID) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + handle := ts.client.ScheduleClient().GetHandle(ctx, scheduleID) + err := handle.Delete(ctx) + if err != nil { + return JobResponse{Success: false, Message: fmt.Sprintf("Failed to delete schedule: %v", err)} + } + + // Remove from memory + delete(ts.scheduleJobs, req.JobID) + + log.Printf("Deleted schedule for job: %s", req.JobID) + return JobResponse{Success: true, Message: "Schedule deleted successfully"} +} + +func (ts *TemporalService) pauseSchedule(req JobRequest) JobResponse { + if req.JobID == "" { + return JobResponse{Success: false, Message: "Missing job_id"} + } + + scheduleID := fmt.Sprintf("goose-job-%s", req.JobID) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + handle := ts.client.ScheduleClient().GetHandle(ctx, scheduleID) + err := handle.Pause(ctx, client.SchedulePauseOptions{ + Note: "Paused via API", + }) + if err != nil { + return JobResponse{Success: false, Message: fmt.Sprintf("Failed to pause schedule: %v", err)} + } + + // Update in memory + if job, exists := ts.scheduleJobs[req.JobID]; exists { + job.Paused = true + } + + log.Printf("Paused schedule for job: %s", req.JobID) + return JobResponse{Success: true, Message: "Schedule paused successfully"} +} + +func (ts *TemporalService) unpauseSchedule(req JobRequest) JobResponse { + if req.JobID == "" { + return JobResponse{Success: false, Message: "Missing job_id"} + } + + scheduleID := fmt.Sprintf("goose-job-%s", req.JobID) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + handle := ts.client.ScheduleClient().GetHandle(ctx, scheduleID) + err := handle.Unpause(ctx, client.ScheduleUnpauseOptions{ + Note: "Unpaused via API", + }) + if err != nil { + return JobResponse{Success: false, Message: fmt.Sprintf("Failed to unpause schedule: %v", err)} + } + + // Update in memory + if job, exists := ts.scheduleJobs[req.JobID]; exists { + job.Paused = false + } + + log.Printf("Unpaused schedule for job: %s", req.JobID) + return JobResponse{Success: true, Message: "Schedule unpaused successfully"} +} + +func (ts *TemporalService) listSchedules() JobResponse { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // List all schedules from Temporal + iter, err := ts.client.ScheduleClient().List(ctx, client.ScheduleListOptions{}) + if err != nil { + return JobResponse{Success: false, Message: fmt.Sprintf("Failed to list schedules: %v", err)} + } + + var jobs []JobStatus + for iter.HasNext() { + schedule, err := iter.Next() + if err != nil { + log.Printf("Error listing schedules: %v", err) + continue + } + + // Extract job ID from schedule ID + if strings.HasPrefix(schedule.ID, "goose-job-") { + jobID := strings.TrimPrefix(schedule.ID, "goose-job-") + + // Get additional details from in-memory tracking + var jobStatus JobStatus + if tracked, exists := ts.scheduleJobs[jobID]; exists { + jobStatus = *tracked + } else { + // Fallback for schedules not in memory + jobStatus = JobStatus{ + ID: jobID, + CreatedAt: time.Now(), // We don't have the real creation time + } + } + + // Update with Temporal schedule info + if len(schedule.Spec.CronExpressions) > 0 { + jobStatus.CronExpr = schedule.Spec.CronExpressions[0] + } + + // Get detailed schedule information including paused state and running status + scheduleHandle := ts.client.ScheduleClient().GetHandle(ctx, schedule.ID) + if desc, err := scheduleHandle.Describe(ctx); err == nil { + jobStatus.Paused = desc.Schedule.State.Paused + + // Check if there are any running workflows for this job + jobStatus.CurrentlyRunning = ts.isJobCurrentlyRunning(ctx, jobID) + + // Update last run time if available + if len(desc.Info.RecentActions) > 0 { + lastAction := desc.Info.RecentActions[len(desc.Info.RecentActions)-1] + if !lastAction.ActualTime.IsZero() { + lastRunStr := lastAction.ActualTime.Format(time.RFC3339) + jobStatus.LastRun = &lastRunStr + } + } + + // Update next run time if available - this field may not exist in older SDK versions + // We'll skip this for now to avoid compilation errors } else { - log.Printf("Temporal server not ready yet (attempt %d)", attemptCount) + log.Printf("Warning: Could not get detailed info for schedule %s: %v", schedule.ID, err) } + + // Update in-memory tracking with latest info + ts.scheduleJobs[jobID] = &jobStatus + + jobs = append(jobs, jobStatus) } } + + return JobResponse{Success: true, Jobs: jobs} +} + +// isJobCurrentlyRunning checks if there are any running workflows for the given job ID +func (ts *TemporalService) isJobCurrentlyRunning(ctx context.Context, jobID string) bool { + // Check our in-memory tracking of running jobs + if running, exists := ts.runningJobs[jobID]; exists && running { + return true + } + return false +} + +// markJobAsRunning sets a job as currently running +func (ts *TemporalService) markJobAsRunning(jobID string) { + ts.runningJobs[jobID] = true + log.Printf("Marked job %s as running", jobID) +} + +// markJobAsNotRunning sets a job as not currently running +func (ts *TemporalService) markJobAsNotRunning(jobID string) { + delete(ts.runningJobs, jobID) + log.Printf("Marked job %s as not running", jobID) +} + +func (ts *TemporalService) runNow(req JobRequest) JobResponse { + if req.JobID == "" { + return JobResponse{Success: false, Message: "Missing job_id"} + } + + // Get job details + job, exists := ts.scheduleJobs[req.JobID] + if !exists { + return JobResponse{Success: false, Message: fmt.Sprintf("Job '%s' not found", req.JobID)} + } + + // Execute workflow immediately + workflowOptions := client.StartWorkflowOptions{ + ID: fmt.Sprintf("manual-%s-%d", req.JobID, time.Now().Unix()), + TaskQueue: TaskQueueName, + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + we, err := ts.client.ExecuteWorkflow(ctx, workflowOptions, GooseJobWorkflow, req.JobID, job.RecipePath) + if err != nil { + return JobResponse{Success: false, Message: fmt.Sprintf("Failed to start workflow: %v", err)} + } + + // Don't wait for completion in run_now, just return the workflow ID + log.Printf("Manual execution started for job: %s, workflow: %s", req.JobID, we.GetID()) + return JobResponse{ + Success: true, + Message: "Job execution started", + Data: RunNowResponse{SessionID: we.GetID()}, // Return workflow ID as session ID for now + } +} + +func (ts *TemporalService) writeErrorResponse(w http.ResponseWriter, statusCode int, message string) { + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(JobResponse{Success: false, Message: message}) +} + +func (ts *TemporalService) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "healthy"}) +} + +// handlePorts returns the port configuration for this service +func (ts *TemporalService) handlePorts(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + portInfo := map[string]int{ + "http_port": ts.ports.HTTPPort, + "temporal_port": ts.ports.TemporalPort, + "ui_port": ts.ports.UIPort, + } + + json.NewEncoder(w).Encode(portInfo) } func main() { @@ -365,9 +755,6 @@ func main() { <-sigChan log.Println("Received shutdown signal") - // Kill all managed processes first - globalProcessManager.KillAllProcesses() - // Shutdown HTTP server ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -387,4 +774,4 @@ func main() { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("HTTP server failed: %v", err) } -} +} \ No newline at end of file diff --git a/temporal-service/schedule.go b/temporal-service/schedule.go deleted file mode 100644 index e3191988c975..000000000000 --- a/temporal-service/schedule.go +++ /dev/null @@ -1,716 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "log" - "os" - "path/filepath" - "strings" - "time" - - "go.temporal.io/sdk/client" -) - -type JobStatus struct { - ID string `json:"id"` - CronExpr string `json:"cron"` - RecipePath string `json:"recipe_path"` - LastRun *string `json:"last_run,omitempty"` - NextRun *string `json:"next_run,omitempty"` - CurrentlyRunning bool `json:"currently_running"` - Paused bool `json:"paused"` - CreatedAt time.Time `json:"created_at"` - ExecutionMode *string `json:"execution_mode,omitempty"` // "foreground" or "background" - LastManualRun *string `json:"last_manual_run,omitempty"` // Track manual runs separately -} - -// Request/Response types for HTTP API -type JobRequest struct { - Action string `json:"action"` // create, delete, pause, unpause, list, run_now, kill_job, update - JobID string `json:"job_id"` - CronExpr string `json:"cron"` - RecipePath string `json:"recipe_path"` - ExecutionMode string `json:"execution_mode,omitempty"` // "foreground" or "background" -} - -type JobResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - Jobs []JobStatus `json:"jobs,omitempty"` - Data interface{} `json:"data,omitempty"` -} - -type RunNowResponse struct { - SessionID string `json:"session_id"` -} - -// createSchedule handles the creation of a new schedule -func (ts *TemporalService) createSchedule(req JobRequest) JobResponse { - if req.JobID == "" || req.CronExpr == "" || req.RecipePath == "" { - return JobResponse{Success: false, Message: "Missing required fields: job_id, cron, recipe_path"} - } - - // Check if job already exists - if _, exists := ts.scheduleJobs[req.JobID]; exists { - return JobResponse{Success: false, Message: fmt.Sprintf("Job with ID '%s' already exists", req.JobID)} - } - - // Validate and copy recipe file to managed storage - managedRecipePath, recipeContent, err := ts.storeRecipeForSchedule(req.JobID, req.RecipePath) - if err != nil { - return JobResponse{Success: false, Message: fmt.Sprintf("Failed to store recipe: %v", err)} - } - - scheduleID := fmt.Sprintf("goose-job-%s", req.JobID) - - // Prepare metadata to store with the schedule as a JSON string in the Note field - executionMode := req.ExecutionMode - if executionMode == "" { - executionMode = "background" // Default to background if not specified - } - - scheduleMetadata := map[string]interface{}{ - "job_id": req.JobID, - "cron_expr": req.CronExpr, - "recipe_path": managedRecipePath, // Use managed path - "original_path": req.RecipePath, // Keep original for reference - "execution_mode": executionMode, - "created_at": time.Now().Format(time.RFC3339), - } - - // For small recipes, embed content directly in metadata - if len(recipeContent) < 8192 { // 8KB limit for embedding - scheduleMetadata["recipe_content"] = string(recipeContent) - log.Printf("Embedded recipe content in metadata for job %s (size: %d bytes)", req.JobID, len(recipeContent)) - } else { - log.Printf("Recipe too large for embedding, using managed file for job %s (size: %d bytes)", req.JobID, len(recipeContent)) - } - - metadataJSON, err := json.Marshal(scheduleMetadata) - if err != nil { - return JobResponse{Success: false, Message: fmt.Sprintf("Failed to encode metadata: %v", err)} - } - - // Create Temporal schedule with metadata in Note field - schedule := client.ScheduleOptions{ - ID: scheduleID, - Spec: client.ScheduleSpec{ - CronExpressions: []string{req.CronExpr}, - }, - Action: &client.ScheduleWorkflowAction{ - ID: fmt.Sprintf("workflow-%s-{{.ScheduledTime.Unix}}", req.JobID), - Workflow: GooseJobWorkflow, - Args: []interface{}{req.JobID, req.RecipePath}, - TaskQueue: TaskQueueName, - }, - Note: string(metadataJSON), // Store metadata as JSON in the Note field - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - _, err = ts.client.ScheduleClient().Create(ctx, schedule) - if err != nil { - return JobResponse{Success: false, Message: fmt.Sprintf("Failed to create schedule: %v", err)} - } - - // Track job in memory - ensure execution mode has a default value - jobStatus := &JobStatus{ - ID: req.JobID, - CronExpr: req.CronExpr, - RecipePath: req.RecipePath, - CurrentlyRunning: false, - Paused: false, - CreatedAt: time.Now(), - ExecutionMode: &executionMode, - } - ts.scheduleJobs[req.JobID] = jobStatus - - log.Printf("Created schedule for job: %s", req.JobID) - return JobResponse{Success: true, Message: "Schedule created successfully"} -} - -// deleteSchedule handles the deletion of a schedule -func (ts *TemporalService) deleteSchedule(req JobRequest) JobResponse { - if req.JobID == "" { - return JobResponse{Success: false, Message: "Missing job_id"} - } - - scheduleID := fmt.Sprintf("goose-job-%s", req.JobID) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - handle := ts.client.ScheduleClient().GetHandle(ctx, scheduleID) - err := handle.Delete(ctx) - if err != nil { - return JobResponse{Success: false, Message: fmt.Sprintf("Failed to delete schedule: %v", err)} - } - - // Clean up managed recipe files - ts.cleanupManagedRecipe(req.JobID) - - // Remove from memory - delete(ts.scheduleJobs, req.JobID) - - log.Printf("Deleted schedule for job: %s", req.JobID) - return JobResponse{Success: true, Message: "Schedule deleted successfully"} -} - -// pauseSchedule handles pausing a schedule -func (ts *TemporalService) pauseSchedule(req JobRequest) JobResponse { - if req.JobID == "" { - return JobResponse{Success: false, Message: "Missing job_id"} - } - - scheduleID := fmt.Sprintf("goose-job-%s", req.JobID) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - handle := ts.client.ScheduleClient().GetHandle(ctx, scheduleID) - err := handle.Pause(ctx, client.SchedulePauseOptions{ - Note: "Paused via API", - }) - if err != nil { - return JobResponse{Success: false, Message: fmt.Sprintf("Failed to pause schedule: %v", err)} - } - - // Update in memory - if job, exists := ts.scheduleJobs[req.JobID]; exists { - job.Paused = true - } - - log.Printf("Paused schedule for job: %s", req.JobID) - return JobResponse{Success: true, Message: "Schedule paused successfully"} -} - -// unpauseSchedule handles unpausing a schedule -func (ts *TemporalService) unpauseSchedule(req JobRequest) JobResponse { - if req.JobID == "" { - return JobResponse{Success: false, Message: "Missing job_id"} - } - - scheduleID := fmt.Sprintf("goose-job-%s", req.JobID) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - handle := ts.client.ScheduleClient().GetHandle(ctx, scheduleID) - err := handle.Unpause(ctx, client.ScheduleUnpauseOptions{ - Note: "Unpaused via API", - }) - if err != nil { - return JobResponse{Success: false, Message: fmt.Sprintf("Failed to unpause schedule: %v", err)} - } - - // Update in memory - if job, exists := ts.scheduleJobs[req.JobID]; exists { - job.Paused = false - } - - log.Printf("Unpaused schedule for job: %s", req.JobID) - return JobResponse{Success: true, Message: "Schedule unpaused successfully"} -} - -// updateSchedule handles updating a schedule -func (ts *TemporalService) updateSchedule(req JobRequest) JobResponse { - if req.JobID == "" || req.CronExpr == "" { - return JobResponse{Success: false, Message: "Missing required fields: job_id, cron"} - } - - // Check if job exists - job, exists := ts.scheduleJobs[req.JobID] - if !exists { - return JobResponse{Success: false, Message: fmt.Sprintf("Job with ID '%s' not found", req.JobID)} - } - - // Check if job is currently running - if job.CurrentlyRunning { - return JobResponse{Success: false, Message: fmt.Sprintf("Cannot update schedule '%s' while it's currently running", req.JobID)} - } - - scheduleID := fmt.Sprintf("goose-job-%s", req.JobID) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Get the existing schedule handle - handle := ts.client.ScheduleClient().GetHandle(ctx, scheduleID) - - // Update the schedule with new cron expression while preserving metadata - err := handle.Update(ctx, client.ScheduleUpdateOptions{ - DoUpdate: func(input client.ScheduleUpdateInput) (*client.ScheduleUpdate, error) { - // Update the cron expression - input.Description.Schedule.Spec.CronExpressions = []string{req.CronExpr} - - // Update the cron expression in metadata stored in Note field - if input.Description.Schedule.State.Note != "" { - var metadata map[string]interface{} - if err := json.Unmarshal([]byte(input.Description.Schedule.State.Note), &metadata); err == nil { - metadata["cron_expr"] = req.CronExpr - if updatedMetadataJSON, err := json.Marshal(metadata); err == nil { - input.Description.Schedule.State.Note = string(updatedMetadataJSON) - } - } - } - - return &client.ScheduleUpdate{ - Schedule: &input.Description.Schedule, - }, nil - }, - }) - - if err != nil { - return JobResponse{Success: false, Message: fmt.Sprintf("Failed to update schedule: %v", err)} - } - - // Update in memory - job.CronExpr = req.CronExpr - - log.Printf("Updated schedule for job: %s with new cron: %s", req.JobID, req.CronExpr) - return JobResponse{Success: true, Message: "Schedule updated successfully"} -} - -// listSchedules lists all schedules -func (ts *TemporalService) listSchedules() JobResponse { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // List all schedules from Temporal - iter, err := ts.client.ScheduleClient().List(ctx, client.ScheduleListOptions{}) - if err != nil { - return JobResponse{Success: false, Message: fmt.Sprintf("Failed to list schedules: %v", err)} - } - - var jobs []JobStatus - for iter.HasNext() { - schedule, err := iter.Next() - if err != nil { - log.Printf("Error listing schedules: %v", err) - continue - } - - // Extract job ID from schedule ID - if strings.HasPrefix(schedule.ID, "goose-job-") { - jobID := strings.TrimPrefix(schedule.ID, "goose-job-") - - // Get detailed schedule information to access metadata - scheduleHandle := ts.client.ScheduleClient().GetHandle(ctx, schedule.ID) - desc, err := scheduleHandle.Describe(ctx) - if err != nil { - log.Printf("Warning: Could not get detailed info for schedule %s: %v", schedule.ID, err) - continue - } - - // Initialize job status with defaults - jobStatus := JobStatus{ - ID: jobID, - CurrentlyRunning: ts.isJobCurrentlyRunning(ctx, jobID), - Paused: desc.Schedule.State.Paused, - CreatedAt: time.Now(), // Fallback if not in metadata - } - - // Extract metadata from the schedule's Note field (stored as JSON) - if desc.Schedule.State.Note != "" { - var metadata map[string]interface{} - if err := json.Unmarshal([]byte(desc.Schedule.State.Note), &metadata); err == nil { - // Extract cron expression - if cronExpr, ok := metadata["cron_expr"].(string); ok { - jobStatus.CronExpr = cronExpr - } else if len(desc.Schedule.Spec.CronExpressions) > 0 { - // Fallback to spec if not in metadata - jobStatus.CronExpr = desc.Schedule.Spec.CronExpressions[0] - } - - // Extract recipe path - if recipePath, ok := metadata["recipe_path"].(string); ok { - jobStatus.RecipePath = recipePath - } - - // Extract execution mode - if executionMode, ok := metadata["execution_mode"].(string); ok { - jobStatus.ExecutionMode = &executionMode - } - - // Extract creation time - if createdAtStr, ok := metadata["created_at"].(string); ok { - if createdAt, err := time.Parse(time.RFC3339, createdAtStr); err == nil { - jobStatus.CreatedAt = createdAt - } - } - } else { - log.Printf("Failed to parse metadata from Note field for schedule %s: %v", schedule.ID, err) - // Fallback to spec values - if len(desc.Schedule.Spec.CronExpressions) > 0 { - jobStatus.CronExpr = desc.Schedule.Spec.CronExpressions[0] - } - defaultMode := "background" - jobStatus.ExecutionMode = &defaultMode - } - } else { - // Fallback for schedules without metadata (legacy schedules) - log.Printf("Schedule %s has no metadata, using fallback values", schedule.ID) - if len(desc.Schedule.Spec.CronExpressions) > 0 { - jobStatus.CronExpr = desc.Schedule.Spec.CronExpressions[0] - } - // For legacy schedules, we can't recover recipe path or execution mode - defaultMode := "background" - jobStatus.ExecutionMode = &defaultMode - } - - // Update last run time - use the most recent between scheduled and manual runs - var mostRecentRun *string - - // Check scheduled runs from Temporal - if len(desc.Info.RecentActions) > 0 { - lastAction := desc.Info.RecentActions[len(desc.Info.RecentActions)-1] - if !lastAction.ActualTime.IsZero() { - scheduledRunStr := lastAction.ActualTime.Format(time.RFC3339) - mostRecentRun = &scheduledRunStr - log.Printf("Job %s scheduled run: %s", jobID, scheduledRunStr) - } - } - - // Check manual runs from our in-memory tracking (if available) - if tracked, exists := ts.scheduleJobs[jobID]; exists && tracked.LastManualRun != nil { - log.Printf("Job %s manual run: %s", jobID, *tracked.LastManualRun) - - // Compare times if we have both - if mostRecentRun != nil { - scheduledTime, err1 := time.Parse(time.RFC3339, *mostRecentRun) - manualTime, err2 := time.Parse(time.RFC3339, *tracked.LastManualRun) - - if err1 == nil && err2 == nil { - if manualTime.After(scheduledTime) { - mostRecentRun = tracked.LastManualRun - log.Printf("Job %s: manual run is more recent", jobID) - } else { - log.Printf("Job %s: scheduled run is more recent", jobID) - } - } - } else { - // Only manual run available - mostRecentRun = tracked.LastManualRun - log.Printf("Job %s: only manual run available", jobID) - } - } - - if mostRecentRun != nil { - jobStatus.LastRun = mostRecentRun - } else { - log.Printf("Job %s has no runs (scheduled or manual)", jobID) - } - - // Update in-memory tracking with latest info for manual run tracking - ts.scheduleJobs[jobID] = &jobStatus - - jobs = append(jobs, jobStatus) - } - } - - return JobResponse{Success: true, Jobs: jobs} -} - -// runNow executes a job immediately -func (ts *TemporalService) runNow(req JobRequest) JobResponse { - if req.JobID == "" { - return JobResponse{Success: false, Message: "Missing job_id"} - } - - // Get job details - job, exists := ts.scheduleJobs[req.JobID] - if !exists { - return JobResponse{Success: false, Message: fmt.Sprintf("Job '%s' not found", req.JobID)} - } - - // Record the manual run time - now := time.Now() - manualRunStr := now.Format(time.RFC3339) - job.LastManualRun = &manualRunStr - log.Printf("Recording manual run for job %s at %s", req.JobID, manualRunStr) - - // Execute workflow immediately - workflowOptions := client.StartWorkflowOptions{ - ID: fmt.Sprintf("manual-%s-%d", req.JobID, now.Unix()), - TaskQueue: TaskQueueName, - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - we, err := ts.client.ExecuteWorkflow(ctx, workflowOptions, GooseJobWorkflow, req.JobID, job.RecipePath) - if err != nil { - return JobResponse{Success: false, Message: fmt.Sprintf("Failed to start workflow: %v", err)} - } - - // Track the workflow for this job - ts.addRunningWorkflow(req.JobID, we.GetID()) - - // Don't wait for completion in run_now, just return the workflow ID - log.Printf("Manual execution started for job: %s, workflow: %s", req.JobID, we.GetID()) - return JobResponse{ - Success: true, - Message: "Job execution started", - Data: RunNowResponse{SessionID: we.GetID()}, // Return workflow ID as session ID for now - } -} - -// killJob kills a running job -func (ts *TemporalService) killJob(req JobRequest) JobResponse { - if req.JobID == "" { - return JobResponse{Success: false, Message: "Missing job_id"} - } - - // Check if job exists - _, exists := ts.scheduleJobs[req.JobID] - if !exists { - return JobResponse{Success: false, Message: fmt.Sprintf("Job '%s' not found", req.JobID)} - } - - // Check if job is currently running - if !ts.isJobCurrentlyRunning(context.Background(), req.JobID) { - return JobResponse{Success: false, Message: fmt.Sprintf("Job '%s' is not currently running", req.JobID)} - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - log.Printf("Starting kill process for job %s", req.JobID) - - // Step 1: Kill managed processes first - processKilled := false - if err := globalProcessManager.KillProcess(req.JobID); err != nil { - log.Printf("Failed to kill managed process for job %s: %v", req.JobID, err) - } else { - log.Printf("Successfully killed managed process for job %s", req.JobID) - processKilled = true - } - - // Step 2: Terminate Temporal workflows - workflowsKilled := 0 - workflowIDs, exists := ts.runningWorkflows[req.JobID] - if exists && len(workflowIDs) > 0 { - for _, workflowID := range workflowIDs { - // Terminate the workflow - err := ts.client.TerminateWorkflow(ctx, workflowID, "", "Killed by user request") - if err != nil { - log.Printf("Error terminating workflow %s for job %s: %v", workflowID, req.JobID, err) - continue - } - log.Printf("Terminated workflow %s for job %s", workflowID, req.JobID) - workflowsKilled++ - } - log.Printf("Terminated %d workflow(s) for job %s", workflowsKilled, req.JobID) - } - - // Step 3: Find and kill any remaining processes by name/pattern - additionalKills := FindAndKillProcessesByPattern(req.JobID) - - // Step 4: Mark job as not running in our tracking - ts.markJobAsNotRunning(req.JobID) - - // Prepare response message - var messages []string - if processKilled { - messages = append(messages, "killed managed process") - } - if workflowsKilled > 0 { - messages = append(messages, fmt.Sprintf("terminated %d workflow(s)", workflowsKilled)) - } - if additionalKills > 0 { - messages = append(messages, fmt.Sprintf("killed %d additional process(es)", additionalKills)) - } - - if len(messages) == 0 { - messages = append(messages, "no active processes found but marked as not running") - } - - log.Printf("Killed job: %s (%s)", req.JobID, strings.Join(messages, ", ")) - return JobResponse{ - Success: true, - Message: fmt.Sprintf("Successfully killed job '%s': %s", req.JobID, strings.Join(messages, ", ")), - } -} - -// inspectJob inspects a running job -func (ts *TemporalService) inspectJob(req JobRequest) JobResponse { - if req.JobID == "" { - return JobResponse{Success: false, Message: "Missing job_id"} - } - - // Check if job exists - _, exists := ts.scheduleJobs[req.JobID] - if !exists { - return JobResponse{Success: false, Message: fmt.Sprintf("Job '%s' not found", req.JobID)} - } - - // Check if job is currently running - if !ts.isJobCurrentlyRunning(context.Background(), req.JobID) { - return JobResponse{Success: false, Message: fmt.Sprintf("Job '%s' is not currently running", req.JobID)} - } - - // Get process information - processes := globalProcessManager.ListProcesses() - if mp, exists := processes[req.JobID]; exists { - duration := time.Since(mp.StartTime) - - inspectData := map[string]interface{}{ - "job_id": req.JobID, - "process_id": mp.Process.Pid, - "running_duration": duration.String(), - "running_duration_seconds": int(duration.Seconds()), - "start_time": mp.StartTime.Format(time.RFC3339), - } - - // Try to get session ID from workflow tracking - if workflowIDs, exists := ts.runningWorkflows[req.JobID]; exists && len(workflowIDs) > 0 { - inspectData["session_id"] = workflowIDs[0] // Use the first workflow ID as session ID - } - - return JobResponse{ - Success: true, - Message: fmt.Sprintf("Job '%s' is running", req.JobID), - Data: inspectData, - } - } - - // If no managed process found, check workflows only - if workflowIDs, exists := ts.runningWorkflows[req.JobID]; exists && len(workflowIDs) > 0 { - inspectData := map[string]interface{}{ - "job_id": req.JobID, - "session_id": workflowIDs[0], - "message": "Job is running but process information not available", - } - - return JobResponse{ - Success: true, - Message: fmt.Sprintf("Job '%s' is running (workflow only)", req.JobID), - Data: inspectData, - } - } - - return JobResponse{ - Success: false, - Message: fmt.Sprintf("Job '%s' appears to be running but no process or workflow information found", req.JobID), - } -} - -// markCompleted marks a job as completed -func (ts *TemporalService) markCompleted(req JobRequest) JobResponse { - if req.JobID == "" { - return JobResponse{Success: false, Message: "Missing job_id"} - } - - // Check if job exists - _, exists := ts.scheduleJobs[req.JobID] - if !exists { - return JobResponse{Success: false, Message: fmt.Sprintf("Job '%s' not found", req.JobID)} - } - - log.Printf("Marking job %s as completed (requested by Rust scheduler)", req.JobID) - - // Mark job as not running in our tracking - ts.markJobAsNotRunning(req.JobID) - - // Also try to clean up any lingering processes - if err := globalProcessManager.KillProcess(req.JobID); err != nil { - log.Printf("No process to clean up for job %s: %v", req.JobID, err) - } - - return JobResponse{ - Success: true, - Message: fmt.Sprintf("Job '%s' marked as completed", req.JobID), - } -} - -// getJobStatus gets the status of a job -func (ts *TemporalService) getJobStatus(req JobRequest) JobResponse { - if req.JobID == "" { - return JobResponse{Success: false, Message: "Missing job_id"} - } - - // Check if job exists - job, exists := ts.scheduleJobs[req.JobID] - if !exists { - return JobResponse{Success: false, Message: fmt.Sprintf("Job '%s' not found", req.JobID)} - } - - // Update the currently running status based on our tracking - job.CurrentlyRunning = ts.isJobCurrentlyRunning(context.Background(), req.JobID) - - // Return the job as a single-item array for consistency with list endpoint - jobs := []JobStatus{*job} - - return JobResponse{ - Success: true, - Message: fmt.Sprintf("Status for job '%s'", req.JobID), - Jobs: jobs, - } -} - -// storeRecipeForSchedule copies a recipe file to managed storage and returns the managed path and content -func (ts *TemporalService) storeRecipeForSchedule(jobID, originalPath string) (string, []byte, error) { - // Validate original recipe file exists - if _, err := os.Stat(originalPath); os.IsNotExist(err) { - return "", nil, fmt.Errorf("recipe file not found: %s", originalPath) - } - - // Read the original recipe content - recipeContent, err := os.ReadFile(originalPath) - if err != nil { - return "", nil, fmt.Errorf("failed to read recipe file: %w", err) - } - - // Validate it's a valid recipe by trying to parse it - if _, err := ts.parseRecipeContent(recipeContent); err != nil { - return "", nil, fmt.Errorf("invalid recipe file: %w", err) - } - - // Create managed file path - originalFilename := filepath.Base(originalPath) - ext := filepath.Ext(originalFilename) - if ext == "" { - ext = ".yaml" // Default to yaml if no extension - } - - managedFilename := fmt.Sprintf("%s%s", jobID, ext) - managedPath := filepath.Join(ts.recipesDir, managedFilename) - - // Write to managed storage - if err := os.WriteFile(managedPath, recipeContent, 0644); err != nil { - return "", nil, fmt.Errorf("failed to write managed recipe file: %w", err) - } - - log.Printf("Stored recipe for job %s: %s -> %s (size: %d bytes)", - jobID, originalPath, managedPath, len(recipeContent)) - - return managedPath, recipeContent, nil -} - -// cleanupManagedRecipe removes managed recipe files for a job -func (ts *TemporalService) cleanupManagedRecipe(jobID string) { - // Clean up both permanent and temporary files - patterns := []string{ - fmt.Sprintf("%s.*", jobID), // Permanent files (jobID.yaml, jobID.json, etc.) - fmt.Sprintf("%s-temp.*", jobID), // Temporary files - } - - for _, pattern := range patterns { - matches, err := filepath.Glob(filepath.Join(ts.recipesDir, pattern)) - if err != nil { - log.Printf("Error finding recipe files for cleanup: %v", err) - continue - } - - for _, filePath := range matches { - if err := os.Remove(filePath); err != nil { - log.Printf("Warning: Failed to remove recipe file %s: %v", filePath, err) - } else { - log.Printf("Cleaned up recipe file: %s", filePath) - } - } - } -} diff --git a/temporal-service/service.go b/temporal-service/service.go deleted file mode 100644 index b87d45f5857d..000000000000 --- a/temporal-service/service.go +++ /dev/null @@ -1,283 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "time" - - "go.temporal.io/sdk/client" - "go.temporal.io/sdk/worker" - "gopkg.in/yaml.v2" -) - -// Global service instance for activities to access -var globalService *TemporalService - -// TemporalService manages the Temporal client and provides HTTP API -type TemporalService struct { - client client.Client - worker worker.Worker - scheduleJobs map[string]*JobStatus // In-memory job tracking - runningJobs map[string]bool // Track which jobs are currently running - runningWorkflows map[string][]string // Track workflow IDs for each job - recipesDir string // Directory for managed recipe storage - ports *PortConfig // Port configuration -} - -// NewTemporalService creates a new Temporal service and ensures Temporal server is running -func NewTemporalService() (*TemporalService, error) { - // First, find available ports - ports, err := findAvailablePorts() - if err != nil { - return nil, fmt.Errorf("failed to find available ports: %w", err) - } - - log.Printf("Using ports - Temporal: %d, UI: %d, HTTP: %d", - ports.TemporalPort, ports.UIPort, ports.HTTPPort) - - // Ensure Temporal server is running - if err := ensureTemporalServerRunning(ports); err != nil { - return nil, fmt.Errorf("failed to ensure Temporal server is running: %w", err) - } - - // Set up managed recipes directory in user data directory - recipesDir, err := getManagedRecipesDir() - if err != nil { - return nil, fmt.Errorf("failed to determine managed recipes directory: %w", err) - } - if err := os.MkdirAll(recipesDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create managed recipes directory: %w", err) - } - log.Printf("Using managed recipes directory: %s", recipesDir) - - // Create client (Temporal server should now be running) - c, err := client.Dial(client.Options{ - HostPort: fmt.Sprintf("127.0.0.1:%d", ports.TemporalPort), - Namespace: Namespace, - }) - if err != nil { - return nil, fmt.Errorf("failed to create temporal client: %w", err) - } - - // Create worker - w := worker.New(c, TaskQueueName, worker.Options{}) - w.RegisterWorkflow(GooseJobWorkflow) - w.RegisterActivity(ExecuteGooseRecipe) - - if err := w.Start(); err != nil { - c.Close() - return nil, fmt.Errorf("failed to start worker: %w", err) - } - - log.Printf("Connected to Temporal server successfully on port %d", ports.TemporalPort) - - service := &TemporalService{ - client: c, - worker: w, - scheduleJobs: make(map[string]*JobStatus), - runningJobs: make(map[string]bool), - runningWorkflows: make(map[string][]string), - recipesDir: recipesDir, - ports: ports, - } - - // Set global service for activities - globalService = service - - return service, nil -} - -// Stop gracefully shuts down the Temporal service -func (ts *TemporalService) Stop() { - log.Println("Shutting down Temporal service...") - if ts.worker != nil { - ts.worker.Stop() - } - if ts.client != nil { - ts.client.Close() - } - log.Println("Temporal service stopped") -} - -// GetHTTPPort returns the HTTP port for this service -func (ts *TemporalService) GetHTTPPort() int { - return ts.ports.HTTPPort -} - -// GetTemporalPort returns the Temporal server port for this service -func (ts *TemporalService) GetTemporalPort() int { - return ts.ports.TemporalPort -} - -// GetUIPort returns the Temporal UI port for this service -func (ts *TemporalService) GetUIPort() int { - return ts.ports.UIPort -} - -// HTTP API handlers - -func (ts *TemporalService) handleJobs(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - if r.Method != http.MethodPost { - ts.writeErrorResponse(w, http.StatusMethodNotAllowed, "Method not allowed") - return - } - - var req JobRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - ts.writeErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("Invalid JSON: %v", err)) - return - } - - var resp JobResponse - - switch req.Action { - case "create": - resp = ts.createSchedule(req) - case "delete": - resp = ts.deleteSchedule(req) - case "pause": - resp = ts.pauseSchedule(req) - case "unpause": - resp = ts.unpauseSchedule(req) - case "update": - resp = ts.updateSchedule(req) - case "list": - resp = ts.listSchedules() - case "run_now": - resp = ts.runNow(req) - case "kill_job": - resp = ts.killJob(req) - case "inspect_job": - resp = ts.inspectJob(req) - case "mark_completed": - resp = ts.markCompleted(req) - case "status": - resp = ts.getJobStatus(req) - default: - resp = JobResponse{Success: false, Message: fmt.Sprintf("Unknown action: %s", req.Action)} - } - - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(resp) -} - -func (ts *TemporalService) handleHealth(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"status": "healthy"}) -} - -func (ts *TemporalService) handlePorts(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - - portInfo := map[string]int{ - "http_port": ts.ports.HTTPPort, - "temporal_port": ts.ports.TemporalPort, - "ui_port": ts.ports.UIPort, - } - - json.NewEncoder(w).Encode(portInfo) -} - -// markJobAsRunning sets a job as currently running and tracks the workflow ID -func (ts *TemporalService) markJobAsRunning(jobID string) { - ts.runningJobs[jobID] = true - log.Printf("Marked job %s as running", jobID) -} - -// markJobAsNotRunning sets a job as not currently running and clears workflow tracking -func (ts *TemporalService) markJobAsNotRunning(jobID string) { - delete(ts.runningJobs, jobID) - delete(ts.runningWorkflows, jobID) - log.Printf("Marked job %s as not running", jobID) -} - -// addRunningWorkflow tracks a workflow ID for a job -func (ts *TemporalService) addRunningWorkflow(jobID, workflowID string) { - if ts.runningWorkflows[jobID] == nil { - ts.runningWorkflows[jobID] = make([]string, 0) - } - ts.runningWorkflows[jobID] = append(ts.runningWorkflows[jobID], workflowID) - log.Printf("Added workflow %s for job %s", workflowID, jobID) -} - -// removeRunningWorkflow removes a workflow ID from job tracking -func (ts *TemporalService) removeRunningWorkflow(jobID, workflowID string) { - if workflows, exists := ts.runningWorkflows[jobID]; exists { - for i, id := range workflows { - if id == workflowID { - ts.runningWorkflows[jobID] = append(workflows[:i], workflows[i+1:]...) - break - } - } - if len(ts.runningWorkflows[jobID]) == 0 { - delete(ts.runningWorkflows, jobID) - ts.runningJobs[jobID] = false - } - } -} - -// getEmbeddedRecipeContent retrieves embedded recipe content from schedule metadata -func (ts *TemporalService) getEmbeddedRecipeContent(jobID string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - scheduleID := fmt.Sprintf("goose-job-%s", jobID) - handle := ts.client.ScheduleClient().GetHandle(ctx, scheduleID) - - desc, err := handle.Describe(ctx) - if err != nil { - return "", fmt.Errorf("failed to get schedule description: %w", err) - } - - if desc.Schedule.State.Note == "" { - return "", fmt.Errorf("no metadata found in schedule") - } - - var metadata map[string]interface{} - if err := json.Unmarshal([]byte(desc.Schedule.State.Note), &metadata); err != nil { - return "", fmt.Errorf("failed to parse schedule metadata: %w", err) - } - - if recipeContent, ok := metadata["recipe_content"].(string); ok { - return recipeContent, nil - } - - return "", fmt.Errorf("no embedded recipe content found") -} - -// writeErrorResponse writes a standardized error response -func (ts *TemporalService) writeErrorResponse(w http.ResponseWriter, statusCode int, message string) { - w.WriteHeader(statusCode) - json.NewEncoder(w).Encode(JobResponse{Success: false, Message: message}) -} - -// isJobCurrentlyRunning checks if there are any running workflows for the given job ID -func (ts *TemporalService) isJobCurrentlyRunning(ctx context.Context, jobID string) bool { - // Check our in-memory tracking of running jobs - if running, exists := ts.runningJobs[jobID]; exists && running { - return true - } - return false -} - -// parseRecipeContent parses recipe content from bytes (YAML or JSON) -func (ts *TemporalService) parseRecipeContent(content []byte) (*Recipe, error) { - var recipe Recipe - - // Try YAML first, then JSON - if err := yaml.Unmarshal(content, &recipe); err != nil { - if err := json.Unmarshal(content, &recipe); err != nil { - return nil, fmt.Errorf("failed to parse as YAML or JSON: %w", err) - } - } - - return &recipe, nil -} \ No newline at end of file diff --git a/temporal-service/temporal-service b/temporal-service/temporal-service index ed48911ce62b..b0ea4cd08ea5 100755 Binary files a/temporal-service/temporal-service and b/temporal-service/temporal-service differ diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 88132ea0e2f9..1d6d598be60c 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1137,10 +1137,6 @@ "cron": { "type": "string" }, - "execution_mode": { - "type": "string", - "nullable": true - }, "id": { "type": "string" }, @@ -1974,10 +1970,6 @@ "currently_running": { "type": "boolean" }, - "execution_mode": { - "type": "string", - "nullable": true - }, "id": { "type": "string" }, diff --git a/ui/desktop/package.json b/ui/desktop/package.json index e3a07c1094d8..a2eb5e220ad0 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -15,10 +15,9 @@ "start:test-error": "GOOSE_TEST_ERROR=true electron-forge start", "package": "electron-forge package", "make": "electron-forge make", - "bundle:default": "node scripts/prepare-platform-binaries.js && npm run make && (cd out/Goose-darwin-arm64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose.zip) || echo 'out/Goose-darwin-arm64 not found; either the binary is not built or you are not on macOS'", - "bundle:alpha": "ALPHA=true node scripts/prepare-platform-binaries.js && ALPHA=true npm run make && (cd out/Goose-darwin-arm64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose_alpha.zip) || echo 'out/Goose-darwin-arm64 not found; either the binary is not built or you are not on macOS'", - "bundle:windows": "node scripts/build-main.js && ELECTRON_PLATFORM=win32 node scripts/prepare-platform-binaries.js && npm run make -- --platform=win32 --arch=x64", - "bundle:intel": "node scripts/prepare-platform-binaries.js && npm run make -- --arch=x64 && cd out/Goose-darwin-x64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose_intel_mac.zip", + "bundle:default": "npm run make && (cd out/Goose-darwin-arm64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose.zip) || echo 'out/Goose-darwin-arm64 not found; either the binary is not built or you are not on macOS'", + "bundle:windows": "node scripts/build-main.js && node scripts/prepare-platform.js && npm run make -- --platform=win32 --arch=x64 && node scripts/copy-windows-dlls.js", + "bundle:intel": "npm run make -- --arch=x64 && cd out/Goose-darwin-x64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose_intel_mac.zip", "debug": "echo 'run --remote-debugging-port=8315' && lldb out/Goose-darwin-arm64/Goose.app", "test-e2e": "npm run generate-api && playwright test", "test-e2e:dev": "npm run generate-api && playwright test --reporter=list --retries=0 --max-failures=1", diff --git a/ui/desktop/scripts/prepare-platform-binaries.js b/ui/desktop/scripts/prepare-platform-binaries.js deleted file mode 100644 index 60a391bad792..000000000000 --- a/ui/desktop/scripts/prepare-platform-binaries.js +++ /dev/null @@ -1,155 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -// Paths -const srcBinDir = path.join(__dirname, '..', 'src', 'bin'); -const platformWinDir = path.join(__dirname, '..', 'src', 'platform', 'windows', 'bin'); - -// Platform-specific file patterns -const windowsFiles = [ - '*.exe', - '*.dll', - '*.cmd', - 'goose-npm/**/*' -]; - -const macosFiles = [ - 'goosed', - 'goose', - 'temporal', - 'temporal-service', - 'jbang', - 'npx', - 'uvx', - '*.db', - '*.log', - '.gitkeep' -]; - -// Helper function to check if file matches patterns -function matchesPattern(filename, patterns) { - return patterns.some(pattern => { - if (pattern.includes('**')) { - // Handle directory patterns - const basePattern = pattern.split('/**')[0]; - return filename.startsWith(basePattern); - } else if (pattern.includes('*')) { - // Handle wildcard patterns - be more precise with file extensions - if (pattern.startsWith('*.')) { - // For file extension patterns like *.exe, *.dll - const extension = pattern.substring(2); // Remove "*." - return filename.endsWith('.' + extension); - } else { - // For other wildcard patterns - const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); - return regex.test(filename); - } - } else { - // Exact match - return filename === pattern; - } - }); -} - -// Helper function to clean directory of cross-platform files -function cleanBinDirectory(targetPlatform) { - console.log(`Cleaning bin directory for ${targetPlatform} build...`); - - if (!fs.existsSync(srcBinDir)) { - console.log('src/bin directory does not exist, skipping cleanup'); - return; - } - - const files = fs.readdirSync(srcBinDir, { withFileTypes: true }); - - files.forEach(file => { - const filePath = path.join(srcBinDir, file.name); - - if (targetPlatform === 'darwin' || targetPlatform === 'linux') { - // For macOS/Linux, remove Windows-specific files - if (matchesPattern(file.name, windowsFiles)) { - console.log(`Removing Windows file: ${file.name}`); - if (file.isDirectory()) { - fs.rmSync(filePath, { recursive: true, force: true }); - } else { - fs.unlinkSync(filePath); - } - } - } else if (targetPlatform === 'win32') { - // For Windows, remove macOS-specific files (keep only Windows files and common files) - if (!matchesPattern(file.name, windowsFiles) && !matchesPattern(file.name, ['*.db', '*.log', '.gitkeep'])) { - // Check if it's a macOS binary (executable without extension) - if (file.isFile() && !path.extname(file.name) && file.name !== '.gitkeep') { - try { - // Check if file is executable (likely a macOS binary) - const stats = fs.statSync(filePath); - if (stats.mode & parseInt('111', 8)) { // Check if any execute bit is set - console.log(`Removing macOS binary: ${file.name}`); - fs.unlinkSync(filePath); - } - } catch (err) { - console.warn(`Could not check file ${file.name}:`, err.message); - } - } - } - } - }); -} - -// Helper function to copy platform-specific files -function copyPlatformFiles(targetPlatform) { - if (targetPlatform === 'win32') { - console.log('Copying Windows-specific files...'); - - if (!fs.existsSync(platformWinDir)) { - console.warn('Windows platform directory does not exist'); - return; - } - - // Ensure src/bin exists - if (!fs.existsSync(srcBinDir)) { - fs.mkdirSync(srcBinDir, { recursive: true }); - } - - // Copy Windows-specific files - const files = fs.readdirSync(platformWinDir, { withFileTypes: true }); - files.forEach(file => { - if (file.name === 'README.md' || file.name === '.gitignore') { - return; - } - - const srcPath = path.join(platformWinDir, file.name); - const destPath = path.join(srcBinDir, file.name); - - if (file.isDirectory()) { - fs.cpSync(srcPath, destPath, { recursive: true, force: true }); - console.log(`Copied directory: ${file.name}`); - } else { - fs.copyFileSync(srcPath, destPath); - console.log(`Copied: ${file.name}`); - } - }); - } -} - -// Main function -function preparePlatformBinaries() { - const targetPlatform = process.env.ELECTRON_PLATFORM || process.platform; - - console.log(`Preparing binaries for platform: ${targetPlatform}`); - - // First copy platform-specific files if needed - copyPlatformFiles(targetPlatform); - - // Then clean up cross-platform files - cleanBinDirectory(targetPlatform); - - console.log('Platform binary preparation complete'); -} - -// Run if called directly -if (require.main === module) { - preparePlatformBinaries(); -} - -module.exports = { preparePlatformBinaries }; \ No newline at end of file diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 2cc73e43aed7..e5fc89db4894 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -64,7 +64,6 @@ export type ContextManageResponse = { export type CreateScheduleRequest = { cron: string; - execution_mode?: string | null; id: string; recipe_source: string; }; @@ -329,7 +328,6 @@ export type ScheduledJob = { cron: string; current_session_id?: string | null; currently_running?: boolean; - execution_mode?: string | null; id: string; last_run?: string | null; paused?: boolean; diff --git a/ui/desktop/src/bin/jbang b/ui/desktop/src/bin/jbang old mode 100644 new mode 100755 diff --git a/ui/desktop/src/bin/npx b/ui/desktop/src/bin/npx old mode 100644 new mode 100755 index e69de29bb2d1..e3faa03b3138 --- a/ui/desktop/src/bin/npx +++ b/ui/desktop/src/bin/npx @@ -0,0 +1,105 @@ +#!/bin/bash + +# Enable strict mode to exit on errors and unset variables +set -euo pipefail + +# Set log file +LOG_FILE="/tmp/mcp.log" + +# Clear the log file at the start +> "$LOG_FILE" + +# Function for logging +log() { + local MESSAGE="$1" + echo "$(date +'%Y-%m-%d %H:%M:%S') - $MESSAGE" | tee -a "$LOG_FILE" +} + +# Trap errors and log them before exiting +trap 'log "An error occurred. Exiting with status $?."' ERR + +log "Starting npx setup script." + +# Ensure ~/.config/goose/mcp-hermit/bin exists +log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist." +mkdir -p ~/.config/goose/mcp-hermit/bin + +# Change to the ~/.config/goose/mcp-hermit directory +log "Changing to directory ~/.config/goose/mcp-hermit." +cd ~/.config/goose/mcp-hermit + + +# Check if hermit binary exists and download if not +if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then + log "Hermit binary not found. Downloading hermit binary." + curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \ + | gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit + log "Hermit binary downloaded and made executable." +else + log "Hermit binary already exists. Skipping download." +fi + + +log "setting hermit cache to be local for MCP servers" +mkdir -p ~/.config/goose/mcp-hermit/cache +export HERMIT_STATE_DIR=~/.config/goose/mcp-hermit/cache + + +# Update PATH +export PATH=~/.config/goose/mcp-hermit/bin:$PATH +log "Updated PATH to include ~/.config/goose/mcp-hermit/bin." + + +# Verify hermit installation +log "Checking for hermit in PATH." +which hermit >> "$LOG_FILE" + +# Initialize hermit +log "Initializing hermit." +hermit init >> "$LOG_FILE" + +# Install Node.js using hermit +log "Installing Node.js with hermit." +hermit install node >> "$LOG_FILE" + +# Verify installations +log "Verifying installation locations:" +log "hermit: $(which hermit)" +log "node: $(which node)" +log "npx: $(which npx)" + + +log "Checking for GOOSE_NPM_REGISTRY and GOOSE_NPM_CERT environment variables for custom npm registry setup..." +# Check if GOOSE_NPM_REGISTRY is set and accessible +if [ -n "${GOOSE_NPM_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_NPM_REGISTRY" > /dev/null; then + log "Checking custom goose registry availability: $GOOSE_NPM_REGISTRY" + log "$GOOSE_NPM_REGISTRY is accessible. Using it for npm registry." + export NPM_CONFIG_REGISTRY="$GOOSE_NPM_REGISTRY" + + # Check if GOOSE_NPM_CERT is set and accessible + if [ -n "${GOOSE_NPM_CERT:-}" ] && curl -s --head --fail "$GOOSE_NPM_CERT" > /dev/null; then + log "Downloading certificate from: $GOOSE_NPM_CERT" + curl -sSL -o ~/.config/goose/mcp-hermit/cert.pem "$GOOSE_NPM_CERT" + if [ $? -eq 0 ]; then + log "Certificate downloaded successfully." + export NODE_EXTRA_CA_CERTS=~/.config/goose/mcp-hermit/cert.pem + else + log "Unable to download the certificate. Skipping certificate setup." + fi + else + log "GOOSE_NPM_CERT is either not set or not accessible. Skipping certificate setup." + fi + +else + log "GOOSE_NPM_REGISTRY is either not set or not accessible. Falling back to default npm registry." + export NPM_CONFIG_REGISTRY="https://registry.npmjs.org/" +fi + + + + +# Final step: Execute npx with passed arguments +log "Executing 'npx' command with arguments: $*" +npx "$@" || log "Failed to execute 'npx' with arguments: $*" + +log "npx setup script completed successfully." \ No newline at end of file diff --git a/ui/desktop/src/bin/uvx b/ui/desktop/src/bin/uvx old mode 100644 new mode 100755 index e69de29bb2d1..8a1eec121345 --- a/ui/desktop/src/bin/uvx +++ b/ui/desktop/src/bin/uvx @@ -0,0 +1,89 @@ +#!/bin/bash + +# Enable strict mode to exit on errors and unset variables +set -euo pipefail + +# Set log file +LOG_FILE="/tmp/mcp.log" + +# Clear the log file at the start +> "$LOG_FILE" + +# Function for logging +log() { + local MESSAGE="$1" + echo "$(date +'%Y-%m-%d %H:%M:%S') - $MESSAGE" | tee -a "$LOG_FILE" +} + +# Trap errors and log them before exiting +trap 'log "An error occurred. Exiting with status $?."' ERR + +log "Starting uvx setup script." + +# Ensure ~/.config/goose/mcp-hermit/bin exists +log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist." +mkdir -p ~/.config/goose/mcp-hermit/bin + +# Change to the ~/.config/goose/mcp-hermit directory +log "Changing to directory ~/.config/goose/mcp-hermit." +cd ~/.config/goose/mcp-hermit + +# Check if hermit binary exists and download if not +if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then + log "Hermit binary not found. Downloading hermit binary." + curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \ + | gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit + log "Hermit binary downloaded and made executable." +else + log "Hermit binary already exists. Skipping download." +fi + + +log "setting hermit cache to be local for MCP servers" +mkdir -p ~/.config/goose/mcp-hermit/cache +export HERMIT_STATE_DIR=~/.config/goose/mcp-hermit/cache + +# Update PATH +export PATH=~/.config/goose/mcp-hermit/bin:$PATH +log "Updated PATH to include ~/.config/goose/mcp-hermit/bin." + + +# Verify hermit installation +log "Checking for hermit in PATH." +which hermit >> "$LOG_FILE" + +# Initialize hermit +log "Initializing hermit." +hermit init >> "$LOG_FILE" + +# Initialize python >= 3.10 +log "hermit install python 3.10" +hermit install python3@3.10 >> "$LOG_FILE" + +# Install UV for python using hermit +log "Installing UV with hermit." +hermit install uv >> "$LOG_FILE" + +# Verify installations +log "Verifying installation locations:" +log "hermit: $(which hermit)" +log "uv: $(which uv)" +log "uvx: $(which uvx)" + + +log "Checking for GOOSE_UV_REGISTRY environment variable for custom python/pip/UV registry setup..." +# Check if GOOSE_UV_REGISTRY is set and accessible +if [ -n "${GOOSE_UV_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_UV_REGISTRY" > /dev/null; then + log "Checking custom goose registry availability: $GOOSE_UV_REGISTRY" + log "$GOOSE_UV_REGISTRY is accessible, setting it as UV_INDEX_URL. Setting UV_NATIVE_TLS to true." + export UV_INDEX_URL="$GOOSE_UV_REGISTRY" + export UV_NATIVE_TLS=true +else + log "Neither GOOSE_UV_REGISTRY nor UV_INDEX_URL is set. Falling back to default configuration." +fi + +# Final step: Execute uvx with passed arguments +log "Executing 'uvx' command with arguments: $*" +uvx "$@" || log "Failed to execute 'uvx' with arguments: $*" + +log "uvx setup script completed successfully." diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index 23407e8b3627..73414080adb9 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -33,8 +33,6 @@ import { } from './context_management/ChatContextManager'; import { ContextHandler } from './context_management/ContextHandler'; import { LocalMessageStorage } from '../utils/localMessageStorage'; -import { useModelAndProvider } from './ModelAndProviderContext'; -import { getCostForModel } from '../utils/costDatabase'; import { Message, createUserMessage, @@ -108,25 +106,10 @@ function ChatContent({ const [showGame, setShowGame] = useState(false); const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false); const [sessionTokenCount, setSessionTokenCount] = useState(0); - const [sessionInputTokens, setSessionInputTokens] = useState(0); - const [sessionOutputTokens, setSessionOutputTokens] = useState(0); - const [localInputTokens, setLocalInputTokens] = useState(0); - const [localOutputTokens, setLocalOutputTokens] = useState(0); const [ancestorMessages, setAncestorMessages] = useState([]); const [droppedFiles, setDroppedFiles] = useState([]); - const [sessionCosts, setSessionCosts] = useState<{ - [key: string]: { - inputTokens: number; - outputTokens: number; - totalCost: number; - }; - }>({}); - const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false); const scrollRef = useRef(null); - const { currentModel, currentProvider } = useModelAndProvider(); - const prevModelRef = useRef(); - const prevProviderRef = useRef(); const { summaryContent, @@ -144,8 +127,6 @@ function ChatContent({ window.electron.logInfo( 'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2) ); - // Set ready for auto user prompt after component initialization - setReadyForAutoUserPrompt(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Empty dependency array means this runs once on mount; @@ -176,15 +157,10 @@ function ChatContent({ updateMessageStreamBody, notifications, currentModelInfo, - sessionMetadata, } = useMessageStream({ api: getApiUrl('/reply'), initialMessages: chat.messages, - body: { - session_id: chat.id, - session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'), - ...(recipeConfig?.scheduledJobId && { scheduled_job_id: recipeConfig.scheduledJobId }), - }, + body: { session_id: chat.id, session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR') }, onFinish: async (_message, _reason) => { window.electron.stopPowerSaveBlocker(); @@ -329,40 +305,6 @@ function ChatContent({ return recipeConfig?.prompt || ''; }, [recipeConfig?.prompt]); - // Auto-send the prompt for scheduled executions - useEffect(() => { - if ( - recipeConfig?.isScheduledExecution && - recipeConfig?.prompt && - messages.length === 0 && - !isLoading && - readyForAutoUserPrompt - ) { - console.log('Auto-sending prompt for scheduled execution:', recipeConfig.prompt); - - // Create and send the user message - const userMessage = createUserMessage(recipeConfig.prompt); - setLastInteractionTime(Date.now()); - window.electron.startPowerSaveBlocker(); - append(userMessage); - - // Scroll to bottom after sending - setTimeout(() => { - if (scrollRef.current?.scrollToBottom) { - scrollRef.current.scrollToBottom(); - } - }, 100); - } - }, [ - recipeConfig?.isScheduledExecution, - recipeConfig?.prompt, - messages.length, - isLoading, - readyForAutoUserPrompt, - append, - setLastInteractionTime, - ]); - // Handle submit const handleSubmit = (e: React.FormEvent) => { window.electron.startPowerSaveBlocker(); @@ -535,40 +477,12 @@ function ChatContent({ .reverse(); }, [filteredMessages]); - // Simple token estimation function (roughly 4 characters per token) - const estimateTokens = (text: string): number => { - return Math.ceil(text.length / 4); - }; - - // Calculate token counts from messages - useEffect(() => { - let inputTokens = 0; - let outputTokens = 0; - - messages.forEach((message) => { - const textContent = getTextContent(message); - if (textContent) { - const tokens = estimateTokens(textContent); - if (message.role === 'user') { - inputTokens += tokens; - } else if (message.role === 'assistant') { - outputTokens += tokens; - } - } - }); - - setLocalInputTokens(inputTokens); - setLocalOutputTokens(outputTokens); - }, [messages]); - // Fetch session metadata to get token count useEffect(() => { const fetchSessionTokens = async () => { try { const sessionDetails = await fetchSessionDetails(chat.id); setSessionTokenCount(sessionDetails.metadata.total_tokens || 0); - setSessionInputTokens(sessionDetails.metadata.accumulated_input_tokens || 0); - setSessionOutputTokens(sessionDetails.metadata.accumulated_output_tokens || 0); } catch (err) { console.error('Error fetching session token count:', err); } @@ -578,74 +492,6 @@ function ChatContent({ } }, [chat.id, messages]); - // Update token counts when sessionMetadata changes from the message stream - useEffect(() => { - console.log('Session metadata received:', sessionMetadata); - if (sessionMetadata) { - setSessionTokenCount(sessionMetadata.totalTokens || 0); - setSessionInputTokens(sessionMetadata.accumulatedInputTokens || 0); - setSessionOutputTokens(sessionMetadata.accumulatedOutputTokens || 0); - } - }, [sessionMetadata]); - - // Handle model changes and accumulate costs - useEffect(() => { - if ( - prevModelRef.current !== undefined && - prevProviderRef.current !== undefined && - (prevModelRef.current !== currentModel || prevProviderRef.current !== currentProvider) - ) { - // Model/provider has changed, save the costs for the previous model - const prevKey = `${prevProviderRef.current}/${prevModelRef.current}`; - - // Get pricing info for the previous model - const prevCostInfo = getCostForModel(prevProviderRef.current, prevModelRef.current); - - if (prevCostInfo) { - const prevInputCost = - (sessionInputTokens || localInputTokens) * (prevCostInfo.input_token_cost || 0); - const prevOutputCost = - (sessionOutputTokens || localOutputTokens) * (prevCostInfo.output_token_cost || 0); - const prevTotalCost = prevInputCost + prevOutputCost; - - // Save the accumulated costs for this model - setSessionCosts((prev) => ({ - ...prev, - [prevKey]: { - inputTokens: sessionInputTokens || localInputTokens, - outputTokens: sessionOutputTokens || localOutputTokens, - totalCost: prevTotalCost, - }, - })); - } - - // Reset token counters for the new model - setSessionTokenCount(0); - setSessionInputTokens(0); - setSessionOutputTokens(0); - setLocalInputTokens(0); - setLocalOutputTokens(0); - - console.log( - 'Model changed from', - `${prevProviderRef.current}/${prevModelRef.current}`, - 'to', - `${currentProvider}/${currentModel}`, - '- saved costs and reset token counters' - ); - } - - prevModelRef.current = currentModel || undefined; - prevProviderRef.current = currentProvider || undefined; - }, [ - currentModel, - currentProvider, - sessionInputTokens, - sessionOutputTokens, - localInputTokens, - localOutputTokens, - ]); - const handleDrop = (e: React.DragEvent) => { e.preventDefault(); const files = e.dataTransfer.files; @@ -797,12 +643,9 @@ function ChatContent({ setView={setView} hasMessages={hasMessages} numTokens={sessionTokenCount} - inputTokens={sessionInputTokens || localInputTokens} - outputTokens={sessionOutputTokens || localOutputTokens} droppedFiles={droppedFiles} messages={messages} setMessages={setMessages} - sessionCosts={sessionCosts} /> diff --git a/ui/desktop/src/components/RecipeEditor.tsx b/ui/desktop/src/components/RecipeEditor.tsx index e062357fbd76..ebab6469c353 100644 --- a/ui/desktop/src/components/RecipeEditor.tsx +++ b/ui/desktop/src/components/RecipeEditor.tsx @@ -332,13 +332,15 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { {/* Action Buttons */}
- + {process.env.ALPHA && ( + + )}
)} @@ -671,50 +617,6 @@ export const CreateScheduleModal: React.FC = ({ -
- -
-
- - -
- -
- {executionMode === 'background' ? ( -

- Background: Runs silently in the background without opening a - window. Results are saved to session storage. -

- ) : ( -

- Foreground: Opens in a desktop window when the Goose app is - running. Falls back to background if the app is not available. -

- )} -
-
-
-
- {frequency === 'every' && ( -
-
- - setCustomIntervalValue(parseInt(e.target.value) || 1)} - required - /> -
-
- - setSelectedMinute(e.target.value)} + required + /> +
+ )} {(frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly') && (
- {frequency === 'every' && ( -
-
- - setCustomIntervalValue(parseInt(e.target.value) || 1)} - required - /> -
-
- - setSelectedMinute(e.target.value)} + required + /> +
+ )} {(frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly') && (