diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 394a0887b942..001afe190d87 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -83,7 +83,7 @@ jobs: echo "Building with explicit PROTOC path..." cross build --release --target ${TARGET} -p goose-cli -vv - - name: Build temporal-service for target platform + - name: Build temporal-service for target platform using build.sh script run: | source ./bin/activate-hermit export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}" @@ -116,9 +116,12 @@ jobs: ;; esac - echo "Building temporal-service for ${GOOS}/${GOARCH}..." + echo "Building temporal-service for ${GOOS}/${GOARCH} using build.sh script..." cd temporal-service - go build -o "../target/${TARGET}/release/${BINARY_NAME}" main.go + # 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}" echo "temporal-service built successfully for ${TARGET}" - name: Package CLI with temporal-service diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index b52a041d3f12..b6b9d714338a 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -150,13 +150,12 @@ jobs: rustup target add x86_64-apple-darwin cargo build --release -p goose-server --target x86_64-apple-darwin - # Build temporal-service + # Build temporal-service using build.sh script - name: Build temporal-service run: | - echo "Building temporal-service..." + echo "Building temporal-service using build.sh script..." cd temporal-service - go build -o temporal-service main.go - chmod +x temporal-service + ./build.sh 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 753c9c8c1572..f31f80b4c42b 100644 --- a/.github/workflows/bundle-desktop-linux.yml +++ b/.github/workflows/bundle-desktop-linux.yml @@ -142,7 +142,21 @@ jobs: restore-keys: | ${{ runner.os }}-cargo-build- - # 8) Build the Rust goosed binary + # 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 - name: Build goosed binary run: | echo "Building goosed binary for Linux..." @@ -150,7 +164,7 @@ jobs: ls -la target/release/ file target/release/goosed - # 9) Clean up build artifacts to save space + # 11) Clean up build artifacts to save space - name: Clean up build artifacts run: | echo "Cleaning up to save disk space..." @@ -167,16 +181,18 @@ jobs: # Check disk space df -h - # 10) Copy binary to Electron folder - - name: Copy binary into Electron folder + # 12) Copy binaries to Electron folder + - name: Copy binaries into Electron folder run: | - echo "Copying goosed binary to ui/desktop/src/bin/" + echo "Copying binaries 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/ - # 10a) Final cleanup before npm build + # 13) Final cleanup before npm build - name: Final cleanup before npm build run: | echo "Final cleanup before npm build..." @@ -188,7 +204,7 @@ jobs: # Check final disk space df -h - # 12) Install npm dependencies + # 14) Install npm dependencies - name: Install npm dependencies run: | cd ui/desktop @@ -199,7 +215,7 @@ jobs: # Verify installation ls -la node_modules/.bin/ | head -5 - # 13) Build Electron app with Linux makers (.deb and .rpm) + # 15) Build Electron app with Linux makers (.deb and .rpm) - name: Build Linux packages run: | cd ui/desktop @@ -212,7 +228,7 @@ jobs: ls -la out/ find out/ -name "*.deb" -o -name "*.rpm" | head -10 - # 14) List all generated files for debugging + # 16) List all generated files for debugging - name: List generated files run: | echo "=== All files in out/ directory ===" @@ -224,7 +240,7 @@ jobs: echo "=== File sizes ===" find ui/desktop/out/ -name "*.deb" -o -name "*.rpm" -exec ls -lh {} \; - # 15) Upload .deb package + # 17) Upload .deb package - name: Upload .deb package uses: actions/upload-artifact@v4 with: @@ -232,7 +248,7 @@ jobs: path: ui/desktop/out/make/deb/x64/*.deb if-no-files-found: error - # 16) Upload .rpm package + # 18) Upload .rpm package - name: Upload .rpm package uses: actions/upload-artifact@v4 with: @@ -240,7 +256,7 @@ jobs: path: ui/desktop/out/make/rpm/x64/*.rpm if-no-files-found: error - # 17) Create combined artifact with both packages + # 19) 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 9aa51cf1a4f0..37b2cbbf98eb 100644 --- a/.github/workflows/bundle-desktop-windows.yml +++ b/.github/workflows/bundle-desktop-windows.yml @@ -196,15 +196,20 @@ 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 + # 4.5) Build temporal-service for Windows using build.sh script - name: Build temporal-service for Windows run: | - echo "Building temporal-service for Windows..." + 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 "GOOS=windows GOARCH=amd64 go build -o temporal-service.exe main.go" + sh -c " + # Make build.sh executable + chmod +x build.sh + # Set Windows build environment and run build script + GOOS=windows GOARCH=amd64 ./build.sh + " 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 d11e9dea0a8c..b015303c74f4 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -190,13 +190,12 @@ jobs: - name: Build goosed run: source ./bin/activate-hermit && cargo build --release -p goose-server - # Build temporal-service + # Build temporal-service using build.sh script - name: Build temporal-service run: | - echo "Building temporal-service..." + echo "Building temporal-service using build.sh script..." cd temporal-service - go build -o temporal-service main.go - chmod +x temporal-service + ./build.sh echo "temporal-service built successfully" # Install and prepare temporal CLI diff --git a/.gitignore b/.gitignore index d9b45401c9bb..ba371c7c712a 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,8 @@ 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 7175c7dbcef3..c03cea02bbca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3639,22 +3639,6 @@ 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.29" diff --git a/Justfile b/Justfile index 1d06c817fc64..9641cf285d54 100644 --- a/Justfile +++ b/Justfile @@ -59,6 +59,13 @@ 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/; \ @@ -83,6 +90,13 @@ 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/; \ @@ -108,6 +122,12 @@ 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/; \ @@ -134,6 +154,12 @@ 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 @@ -160,6 +186,11 @@ 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 @@ -172,25 +203,8 @@ 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 && 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/) && \ + (cd ui/desktop && npm run bundle:windows) && \ echo "Windows package build complete!"; \ else \ echo "Windows binary not found."; \ @@ -202,10 +216,50 @@ make-ui-intel: @just release-intel cd ui/desktop && npm run bundle:intel -# Setup langfuse server -langfuse-server: - #!/usr/bin/env bash - ./scripts/setup_langfuse.sh +# 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" # Run UI with debug build run-dev: diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 7a7f647da361..23dcc934d18d 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -28,6 +28,7 @@ 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 6a69718232e5..9824c66a5874 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -508,6 +508,16 @@ 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 @@ -661,6 +671,7 @@ pub async fn cli() -> Result<()> { settings: None, debug, max_tool_repetitions, + scheduled_job_id: None, interactive: true, quiet: false, }) @@ -708,6 +719,7 @@ pub async fn cli() -> Result<()> { params, explain, render_recipe, + scheduled_job_id, quiet, }) => { let (input_config, session_settings) = match ( @@ -807,6 +819,7 @@ 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, }) @@ -924,6 +937,7 @@ 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, }) diff --git a/crates/goose-cli/src/commands/bench.rs b/crates/goose-cli/src/commands/bench.rs index 093ca3a9b2b6..cf3d992e46c5 100644 --- a/crates/goose-cli/src/commands/bench.rs +++ b/crates/goose-cli/src/commands/bench.rs @@ -44,6 +44,7 @@ pub async fn agent_generator( debug: false, max_tool_repetitions: None, interactive: false, // Benchmarking is non-interactive + scheduled_job_id: None, quiet: false, }) .await; diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs index 771ac998ae3f..4d94c6de8e80 100644 --- a/crates/goose-cli/src/commands/schedule.rs +++ b/crates/goose-cli/src/commands/schedule.rs @@ -99,6 +99,7 @@ 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/web.rs b/crates/goose-cli/src/commands/web.rs index a50b8c52611c..348b5a22295f 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -464,6 +464,7 @@ 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 2140a6dce27b..15397ab11355 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -40,6 +40,8 @@ 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 @@ -115,7 +117,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); + let mut debug_session = Session::new(debug_agent, temp_session_file.clone(), false, None); // Process the debugging request println!("{}", style("Analyzing the extension failure...").yellow()); @@ -341,7 +343,12 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session { } // Create new session - let mut session = Session::new(agent, session_file.clone(), session_config.debug); + let mut session = Session::new( + agent, + session_file.clone(), + session_config.debug, + session_config.scheduled_job_id.clone(), + ); // Add extensions if provided for extension_str in session_config.extensions { @@ -490,6 +497,7 @@ mod tests { settings: None, debug: true, max_tool_repetitions: Some(5), + scheduled_job_id: None, interactive: true, quiet: false, }; @@ -499,6 +507,7 @@ 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); } @@ -517,6 +526,7 @@ 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 0010dbc91463..18916550629a 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -51,6 +51,7 @@ 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 @@ -107,7 +108,12 @@ pub async fn classify_planner_response( } impl Session { - pub fn new(agent: Agent, session_file: PathBuf, debug: bool) -> Self { + pub fn new( + agent: Agent, + session_file: PathBuf, + debug: bool, + scheduled_job_id: Option, + ) -> Self { let messages = match session::read_messages(&session_file) { Ok(msgs) => msgs, Err(e) => { @@ -123,6 +129,7 @@ impl Session { completion_cache: Arc::new(std::sync::RwLock::new(CompletionCache::new())), debug, run_mode: RunMode::Normal, + scheduled_job_id, } } @@ -307,7 +314,13 @@ impl Session { let provider = self.agent.provider().await?; // Persist messages with provider for automatic description generation - session::persist_messages(&self.session_file, &self.messages, Some(provider)).await?; + session::persist_messages_with_schedule_id( + &self.session_file, + &self.messages, + Some(provider), + self.scheduled_job_id.clone(), + ) + .await?; // Track the current directory and last instruction in projects.json let session_id = self @@ -413,10 +426,11 @@ impl Session { let provider = self.agent.provider().await?; // Persist messages with provider for automatic description generation - session::persist_messages( + session::persist_messages_with_schedule_id( &self.session_file, &self.messages, Some(provider), + self.scheduled_job_id.clone(), ) .await?; @@ -600,10 +614,11 @@ impl Session { self.messages = summarized_messages; // Persist the summarized messages - session::persist_messages( + session::persist_messages_with_schedule_id( &self.session_file, &self.messages, Some(provider), + self.scheduled_job_id.clone(), ) .await?; @@ -727,7 +742,8 @@ impl Session { id: session_id.clone(), working_dir: std::env::current_dir() .expect("failed to get current session working directory"), - schedule_id: None, + schedule_id: self.scheduled_job_id.clone(), + execution_mode: None, }), ) .await?; @@ -776,7 +792,7 @@ impl Session { Err(ToolError::ExecutionError("Tool call cancelled by user".to_string())) )); self.messages.push(response_message); - session::persist_messages(&self.session_file, &self.messages, None).await?; + session::persist_messages_with_schedule_id(&self.session_file, &self.messages, None, self.scheduled_job_id.clone()).await?; drop(stream); break; @@ -862,7 +878,8 @@ impl Session { id: session_id.clone(), working_dir: std::env::current_dir() .expect("failed to get current session working directory"), - schedule_id: None, + schedule_id: self.scheduled_job_id.clone(), + execution_mode: None, }), ) .await?; @@ -872,7 +889,7 @@ impl Session { self.messages.push(message.clone()); // No need to update description on assistant messages - session::persist_messages(&self.session_file, &self.messages, None).await?; + session::persist_messages_with_schedule_id(&self.session_file, &self.messages, None, self.scheduled_job_id.clone()).await?; if interactive {output::hide_thinking()}; let _ = progress_bars.hide(); @@ -1006,7 +1023,13 @@ impl Session { self.messages.push(response_message); // No need for description update here - session::persist_messages(&self.session_file, &self.messages, None).await?; + session::persist_messages_with_schedule_id( + &self.session_file, + &self.messages, + None, + self.scheduled_job_id.clone(), + ) + .await?; let prompt = format!( "The existing call to {} was interrupted. How would you like to proceed?", @@ -1015,7 +1038,13 @@ impl Session { self.messages.push(Message::assistant().with_text(&prompt)); // No need for description update here - session::persist_messages(&self.session_file, &self.messages, None).await?; + session::persist_messages_with_schedule_id( + &self.session_file, + &self.messages, + None, + self.scheduled_job_id.clone(), + ) + .await?; output::render_message(&Message::assistant().with_text(&prompt), self.debug); } else { @@ -1029,8 +1058,13 @@ impl Session { self.messages.push(Message::assistant().with_text(prompt)); // No need for description update here - session::persist_messages(&self.session_file, &self.messages, None) - .await?; + session::persist_messages_with_schedule_id( + &self.session_file, + &self.messages, + None, + self.scheduled_job_id.clone(), + ) + .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 deleted file mode 100644 index 0178dda52b37..000000000000 --- a/crates/goose-scheduler-executor/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[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 deleted file mode 100644 index d9e59f5e6d1f..000000000000 --- a/crates/goose-scheduler-executor/src/main.rs +++ /dev/null @@ -1,215 +0,0 @@ -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 bb16f3c6be82..5529dee7cbfb 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -40,6 +40,7 @@ struct ChatRequest { messages: Vec, session_id: Option, session_working_dir: String, + scheduled_job_id: Option, } pub struct SseResponse { @@ -181,7 +182,8 @@ async fn handler( Some(SessionConfig { id: session::Identifier::Name(session_id.clone()), working_dir: PathBuf::from(session_working_dir), - schedule_id: None, + schedule_id: request.scheduled_job_id.clone(), + execution_mode: None, }), ) .await @@ -303,6 +305,7 @@ struct AskRequest { prompt: String, session_id: Option, session_working_dir: String, + scheduled_job_id: Option, } #[derive(Debug, Serialize)] @@ -339,7 +342,8 @@ async fn ask_handler( Some(SessionConfig { id: session::Identifier::Name(session_id.clone()), working_dir: PathBuf::from(session_working_dir), - schedule_id: None, + schedule_id: request.scheduled_job_id.clone(), + execution_mode: None, }), ) .await @@ -578,6 +582,7 @@ 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 9b4433c52ee4..64df30aa5625 100644 --- a/crates/goose-server/src/routes/schedule.rs +++ b/crates/goose-server/src/routes/schedule.rs @@ -19,6 +19,8 @@ pub struct CreateScheduleRequest { id: String, recipe_source: String, cron: String, + #[serde(default)] + execution_mode: Option, // "foreground" or "background" } #[derive(Deserialize, Serialize, utoipa::ToSchema)] @@ -124,6 +126,7 @@ 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/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 3f56dacc230b..0351c625e010 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -577,7 +577,25 @@ impl Agent { let (mut tools, mut toolshim_tools, mut system_prompt) = self.prepare_tools_and_prompt().await?; - let goose_mode = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string()); + // 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 (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 01f59f958fd8..037c25905ab6 100644 --- a/crates/goose/src/agents/platform_tools.rs +++ b/crates/goose/src/agents/platform_tools.rs @@ -144,6 +144,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 six field cron expression for create action"}, + "execution_mode": {"type": "string", "description": "Execution mode for create action: 'foreground' or 'background'", "enum": ["foreground", "background"], "default": "background"}, "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/schedule_tool.rs b/crates/goose/src/agents/schedule_tool.rs index 1dac50bed51d..e632e8d5c822 100644 --- a/crates/goose/src/agents/schedule_tool.rs +++ b/crates/goose/src/agents/schedule_tool.rs @@ -94,6 +94,20 @@ 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!( @@ -135,12 +149,13 @@ 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 '{}'", - job_id, recipe_path, cron_expression + "Successfully created scheduled job '{}' for recipe '{}' with cron expression '{}' in {} mode", + job_id, recipe_path, cron_expression, execution_mode ))]), Err(e) => Err(ToolError::ExecutionError(format!( "Failed to create job: {}", diff --git a/crates/goose/src/agents/types.rs b/crates/goose/src/agents/types.rs index 9d23150a5191..32c4b15e1dd3 100644 --- a/crates/goose/src/agents/types.rs +++ b/crates/goose/src/agents/types.rs @@ -23,5 +23,7 @@ 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, // NEW + pub schedule_id: Option, + /// Execution mode for scheduled jobs: "foreground" or "background" + pub execution_mode: Option, } diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 4044a491ac35..4ed68dbc7d4e 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -122,6 +122,8 @@ 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( @@ -1059,6 +1061,10 @@ 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 @@ -1091,6 +1097,7 @@ 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 @@ -1323,6 +1330,7 @@ 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 92c698dbd9e8..d044c280cb9b 100644 --- a/crates/goose/src/scheduler_factory.rs +++ b/crates/goose/src/scheduler_factory.rs @@ -15,41 +15,33 @@ impl SchedulerType { pub fn from_config() -> Self { let config = Config::global(); - // 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; - } - } + // Debug logging to help troubleshoot environment variable issues + tracing::debug!("Checking scheduler configuration..."); - // Alpha is enabled, now check scheduler type preference + // Check scheduler type preference from GOOSE_SCHEDULER_TYPE match config.get_param::("GOOSE_SCHEDULER_TYPE") { - 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 + 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 + } } - }, + } Err(_) => { - // 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 + 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 } } } @@ -123,62 +115,38 @@ mod tests { use temp_env::with_vars; #[test] - 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)); - }, - ); + 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)); + }); } #[test] - 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)); - }, - ); + 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)); + }); } #[test] - 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)); - }, - ); + 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)); + }); } #[test] - 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)); - }, - ); + 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)); + }); } } diff --git a/crates/goose/src/session/mod.rs b/crates/goose/src/session/mod.rs index 51d8957db18e..99b6bb08d54f 100644 --- a/crates/goose/src/session/mod.rs +++ b/crates/goose/src/session/mod.rs @@ -3,9 +3,10 @@ pub mod storage; // Re-export common session types and functions pub use storage::{ - 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, + 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, }; pub use info::{get_session_info, SessionInfo}; diff --git a/crates/goose/src/session/storage.rs b/crates/goose/src/session/storage.rs index 08298673dedd..786b09b4d2e3 100644 --- a/crates/goose/src/session/storage.rs +++ b/crates/goose/src/session/storage.rs @@ -437,6 +437,19 @@ 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. +pub async fn persist_messages_with_schedule_id( + session_file: &Path, + messages: &[Message], + provider: Option>, + schedule_id: Option, ) -> Result<()> { // Count user messages let user_message_count = messages @@ -448,11 +461,16 @@ pub async fn persist_messages( match provider { Some(provider) if user_message_count < 4 => { //generate_description is responsible for writing the messages - generate_description(session_file, messages, provider).await + generate_description_with_schedule_id(session_file, messages, provider, schedule_id) + .await } _ => { // Read existing metadata - let metadata = read_metadata(session_file)?; + let mut metadata = read_metadata(session_file)?; + // Update the schedule_id if provided + if schedule_id.is_some() { + metadata.schedule_id = schedule_id; + } // Write the file with metadata and messages save_messages_with_metadata(session_file, &metadata, messages) } @@ -492,6 +510,19 @@ pub async fn generate_description( session_file: &Path, 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. +pub async fn generate_description_with_schedule_id( + session_file: &Path, + messages: &[Message], + provider: Arc, + schedule_id: Option, ) -> Result<()> { // 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(); @@ -527,8 +558,11 @@ pub async fn generate_description( // Read current metadata let mut metadata = read_metadata(session_file)?; - // Update description + // Update description and schedule_id metadata.description = description; + if schedule_id.is_some() { + metadata.schedule_id = schedule_id; + } // Update the file with the new metadata and existing messages save_messages_with_metadata(session_file, &metadata, messages) diff --git a/crates/goose/src/temporal_scheduler.rs b/crates/goose/src/temporal_scheduler.rs index a2d54b9736e2..8fdf81cb3f8f 100644 --- a/crates/goose/src/temporal_scheduler.rs +++ b/crates/goose/src/temporal_scheduler.rs @@ -16,8 +16,9 @@ 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 -const DEFAULT_HTTP_PORTS: &[u16] = &[8080, 8081, 8082, 8083, 8084, 8085]; +// 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]; #[derive(Serialize, Deserialize, Debug)] struct JobRequest { @@ -25,6 +26,7 @@ struct JobRequest { job_id: Option, cron: Option, recipe_path: Option, + execution_mode: Option, } #[derive(Serialize, Deserialize, Debug)] @@ -45,6 +47,7 @@ struct TemporalJobStatus { currently_running: bool, paused: bool, created_at: String, + execution_mode: Option, } #[derive(Serialize, Deserialize, Debug)] @@ -52,13 +55,14 @@ struct RunNowResponse { session_id: String, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct PortConfig { http_port: u16, temporal_port: u16, ui_port: u16, } +#[derive(Clone)] pub struct TemporalScheduler { http_client: Client, service_url: String, @@ -107,50 +111,61 @@ 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 { - // 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"); + async fn discover_http_port(http_client: &Client) -> Result { + info!("Discovering Temporal service port..."); // Check PORT environment variable first if let Ok(port_str) = std::env::var("PORT") { if let Ok(port) = port_str.parse::() { - if Self::is_port_free(port).await { - info!("Using PORT environment variable: {}", port); + 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); return Ok(port); } else { warn!( - "PORT environment variable {} is not free, finding alternative", + "PORT environment variable {} is occupied by non-Temporal service", port ); } } } - // Try to find a free port from the default list + // 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"); + for &port in DEFAULT_HTTP_PORTS { if Self::is_port_free(port).await { - info!("Found free port {} for Temporal service", port); + info!("Found free port {} for new Temporal service", port); return Ok(port); } } // If all default ports are taken, find any free port in a reasonable range - for port in 8086..8200 { + for port in 58086..58200 { if Self::is_port_free(port).await { - info!("Found free port {} for Temporal service", port); + info!("Found free port {} for new Temporal service", port); return Ok(port); } } @@ -160,112 +175,51 @@ impl TemporalScheduler { )) } - async fn is_port_free(port: u16) -> bool { - use std::net::{SocketAddr, TcpListener}; - use std::time::Duration; + /// 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); - 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 - } - } + 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 } Err(_) => { - // Could not bind, port is definitely in use + // Port might be free or occupied by something else false } } } - 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)))?; - - if !pgrep_output.status.success() { - return Err(SchedulerError::SchedulerInternalError( - "No temporal-service processes found".to_string(), - )); - } - - 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(); + async fn is_port_free(port: u16) -> bool { + use std::net::{SocketAddr, TcpListener}; - 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(); + let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap(); - 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); - } - } - } - } - } - } - } + // 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 } } - - Err(SchedulerError::SchedulerInternalError( - "Could not find HTTP API port from temporal-service processes".to_string(), - )) } async fn fetch_port_config(&self) -> Result { @@ -305,7 +259,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 } @@ -366,7 +320,7 @@ impl TemporalScheduler { command.process_group(0); } - let child = command.spawn().map_err(|e| { + let mut child = command.spawn().map_err(|e| { SchedulerError::SchedulerInternalError(format!( "Failed to start Go temporal service: {}", e @@ -379,9 +333,6 @@ 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; @@ -410,6 +361,12 @@ 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(()) } @@ -535,6 +492,7 @@ impl TemporalScheduler { job_id: Some(job.id.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?; @@ -554,6 +512,7 @@ impl TemporalScheduler { job_id: None, cron: None, recipe_path: None, + execution_mode: None, }; let response = self.make_request(request).await?; @@ -572,6 +531,7 @@ 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(); @@ -587,6 +547,7 @@ impl TemporalScheduler { job_id: Some(id.to_string()), cron: None, recipe_path: None, + execution_mode: None, }; let response = self.make_request(request).await?; @@ -605,6 +566,7 @@ impl TemporalScheduler { job_id: Some(id.to_string()), cron: None, recipe_path: None, + execution_mode: None, }; let response = self.make_request(request).await?; @@ -623,6 +585,7 @@ impl TemporalScheduler { job_id: Some(id.to_string()), cron: None, recipe_path: None, + execution_mode: None, }; let response = self.make_request(request).await?; @@ -642,6 +605,7 @@ impl TemporalScheduler { job_id: Some(id.to_string()), cron: None, recipe_path: None, + execution_mode: None, }; let response = self.make_request(request).await?; @@ -717,20 +681,172 @@ impl TemporalScheduler { pub async fn update_schedule( &self, - _sched_id: &str, - _new_cron: String, + sched_id: &str, + new_cron: String, ) -> Result<(), SchedulerError> { - 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(), - )) + tracing::info!( + "TemporalScheduler: update_schedule() called for job '{}' with cron '{}'", + sched_id, + new_cron + ); + + let request = JobRequest { + action: "update".to_string(), + job_id: Some(sched_id.to_string()), + cron: Some(new_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> { - warn!("kill_running_job() method not implemented for TemporalScheduler"); - Err(SchedulerError::SchedulerInternalError( - "kill_running_job not supported by TemporalScheduler".to_string(), - )) + 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 = crate::session::storage::get_path( + crate::session::storage::Identifier::Name(session_name), + ); + + // 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(()) + } + + /// 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 get_running_job_info( @@ -742,24 +858,62 @@ impl TemporalScheduler { sched_id ); - // 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); + // 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, + }; - 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))) + 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 = crate::session::storage::get_path( + crate::session::storage::Identifier::Name(session_name.clone()), + ); + + // 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))); + } + } + } + } + + // 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())) + } } else { - Ok(None) + Err(SchedulerError::JobNotFound(sched_id.to_string())) } } else { - Err(SchedulerError::JobNotFound(sched_id.to_string())) + Err(SchedulerError::SchedulerInternalError(response.message)) } } @@ -1021,17 +1175,43 @@ mod tests { } #[test] - 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); - }; + 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); + } + } - println!("✅ sessions() method signature is correct"); + // 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); + } + } + }); } #[test] diff --git a/crates/goose/tests/test_support.rs b/crates/goose/tests/test_support.rs index cec7cdb2b55b..cfea855b1085 100644 --- a/crates/goose/tests/test_support.rs +++ b/crates/goose/tests/test_support.rs @@ -361,6 +361,7 @@ 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 bb3f98c6f2a3..bdc2901c9d59 100755 --- a/temporal-service/build.sh +++ b/temporal-service/build.sh @@ -14,22 +14,40 @@ if [ ! -f "go.sum" ]; then go mod tidy fi -# Build the service +# 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 echo "Compiling Go binary..." -go build -o temporal-service main.go +if [ -n "${GOOS:-}" ] && [ -n "${GOARCH:-}" ]; then + echo "Cross-compiling for ${GOOS}/${GOARCH}..." + GOOS="${GOOS}" GOARCH="${GOARCH}" go build -o "${BINARY_NAME}" . +else + echo "Building for current platform..." + go build -o "${BINARY_NAME}" . +fi -# Make it executable -chmod +x temporal-service +# Make it executable (skip on Windows as it's not needed) +if [ "${GOOS:-}" != "windows" ]; then + chmod +x "${BINARY_NAME}" +fi echo "Build completed successfully!" -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 +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 diff --git a/temporal-service/goose_workflow.go b/temporal-service/goose_workflow.go new file mode 100644 index 000000000000..581610e1a2ad --- /dev/null +++ b/temporal-service/goose_workflow.go @@ -0,0 +1,578 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/workflow" + "go.temporal.io/sdk/temporal" + "gopkg.in/yaml.v2" +) + +// Recipe represents the structure we need from recipe files +type Recipe struct { + Title string `json:"title" yaml:"title"` + Description string `json:"description" yaml:"description"` + Instructions *string `json:"instructions" yaml:"instructions"` + Prompt *string `json:"prompt" yaml:"prompt"` +} + +// 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) + + 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 + } + + logger.Info("Goose job workflow completed", "jobID", jobID, "sessionID", sessionID) + return sessionID, nil +} + +// Activity definition for executing Goose recipes with proper cancellation handling +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) + } + + // Resolve the actual recipe path (might be embedded in metadata) + actualRecipePath, err := resolveRecipePath(jobID, recipePath) + if err != nil { + return "", temporal.NewNonRetryableApplicationError( + fmt.Sprintf("failed to resolve recipe: %v", err), + "InvalidRecipeError", + err, + ) + } + + // Check if recipe file exists + if _, err := os.Stat(actualRecipePath); os.IsNotExist(err) { + return "", temporal.NewNonRetryableApplicationError( + fmt.Sprintf("recipe file not found: %s", actualRecipePath), + "InvalidRecipeError", + err, + ) + } + + // Create a cancellable context for the subprocess + subCtx, cancel := context.WithCancel(ctx) + defer cancel() + + // Monitor for activity cancellation + go func() { + select { + case <-ctx.Done(): + logger.Info("Activity cancelled, killing process for job", "jobID", jobID) + globalProcessManager.KillProcess(jobID) + case <-subCtx.Done(): + // Normal completion + } + }() + + // Check if this is a foreground job + if isForegroundJob(actualRecipePath) { + logger.Info("Executing foreground job with cancellation support", "jobID", jobID) + return executeForegroundJobWithCancellation(subCtx, jobID, actualRecipePath) + } + + // For background jobs, execute with cancellation support + logger.Info("Executing background job with cancellation support", "jobID", jobID) + return executeBackgroundJobWithCancellation(subCtx, jobID, actualRecipePath) +} + +// resolveRecipePath resolves the actual recipe path, handling embedded recipes +func resolveRecipePath(jobID, recipePath string) (string, error) { + // If the recipe path exists as-is, use it + if _, err := os.Stat(recipePath); err == nil { + return recipePath, nil + } + + // Try to get embedded recipe content from schedule metadata + if globalService != nil { + if recipeContent, err := globalService.getEmbeddedRecipeContent(jobID); err == nil && recipeContent != "" { + // Create a temporary file with the embedded content + tempPath := filepath.Join(globalService.recipesDir, fmt.Sprintf("%s-temp.yaml", jobID)) + if err := os.WriteFile(tempPath, []byte(recipeContent), 0644); err != nil { + return "", fmt.Errorf("failed to write temporary recipe file: %w", err) + } + log.Printf("Created temporary recipe file for job %s: %s", jobID, tempPath) + return tempPath, nil + } + } + + // If no embedded content and original path doesn't exist, return error + return "", fmt.Errorf("recipe not found: %s (and no embedded content available)", recipePath) +} + +// executeBackgroundJobWithCancellation handles background job execution with proper process management +func executeBackgroundJobWithCancellation(ctx context.Context, jobID, recipePath string) (string, error) { + log.Printf("Executing background job %s using recipe file: %s", jobID, recipePath) + + // Find the goose CLI binary + goosePath, err := findGooseBinary() + if err != nil { + return "", fmt.Errorf("failed to find goose CLI binary: %w", err) + } + + // Generate session name for this scheduled job + sessionName := fmt.Sprintf("scheduled-%s", jobID) + + // Create command with context for cancellation + cmd := exec.CommandContext(ctx, goosePath, "run", + "--recipe", recipePath, + "--name", sessionName, + "--scheduled-job-id", jobID, + ) + + // Set up process group for proper cleanup + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, // Create new process group + } + + // Set up environment + cmd.Env = append(os.Environ(), + fmt.Sprintf("GOOSE_JOB_ID=%s", jobID), + ) + + log.Printf("Starting background CLI job %s with session %s", jobID, sessionName) + + // Start the process + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("failed to start background CLI execution: %w", err) + } + + // Register the process with the process manager + _, cancel := context.WithCancel(ctx) + globalProcessManager.AddProcess(jobID, cmd.Process, cancel) + + // Ensure cleanup + defer func() { + globalProcessManager.RemoveProcess(jobID) + cancel() + }() + + // Wait for completion or cancellation + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + + select { + case <-ctx.Done(): + // Context cancelled - kill the process + log.Printf("Background job %s cancelled, killing process", jobID) + globalProcessManager.KillProcess(jobID) + return "", ctx.Err() + case err := <-done: + if err != nil { + log.Printf("Background CLI job %s failed: %v", jobID, err) + return "", fmt.Errorf("background CLI execution failed: %w", err) + } + log.Printf("Background CLI job %s completed successfully with session %s", jobID, sessionName) + return sessionName, nil + } +} + +// executeForegroundJobWithCancellation handles foreground job execution with proper process management +func executeForegroundJobWithCancellation(ctx context.Context, jobID, recipePath string) (string, error) { + log.Printf("Executing foreground job %s with recipe %s", jobID, recipePath) + + // Parse the recipe file first + recipe, err := parseRecipeFile(recipePath) + if err != nil { + return "", fmt.Errorf("failed to parse recipe file: %w", err) + } + + // Check if desktop app is running + if isDesktopAppRunning() { + log.Printf("Desktop app is running, using GUI mode for job %s", jobID) + return executeForegroundJobGUIWithCancellation(ctx, jobID, recipe) + } + + // Desktop app not running, fall back to CLI + log.Printf("Desktop app not running, falling back to CLI mode for job %s", jobID) + return executeForegroundJobCLIWithCancellation(ctx, jobID, recipe, recipePath) +} + +// executeForegroundJobGUIWithCancellation handles GUI execution with cancellation +func executeForegroundJobGUIWithCancellation(ctx context.Context, jobID string, recipe *Recipe) (string, error) { + // Generate session name for this scheduled job + sessionName := fmt.Sprintf("scheduled-%s", jobID) + + // Generate deep link with session name + deepLink, err := generateDeepLink(recipe, jobID, sessionName) + if err != nil { + return "", fmt.Errorf("failed to generate deep link: %w", err) + } + + // Open the deep link + if err := openDeepLink(deepLink); err != nil { + return "", fmt.Errorf("failed to open deep link: %w", err) + } + + log.Printf("Foreground GUI job %s initiated with session %s, waiting for completion...", jobID, sessionName) + + // Wait for session completion with cancellation support + err = waitForSessionCompletionWithCancellation(ctx, sessionName, 2*time.Hour) + if err != nil { + if ctx.Err() != nil { + log.Printf("GUI session %s cancelled", sessionName) + return "", ctx.Err() + } + return "", fmt.Errorf("GUI session failed or timed out: %w", err) + } + + log.Printf("Foreground GUI job %s completed successfully with session %s", jobID, sessionName) + return sessionName, nil +} + +// executeForegroundJobCLIWithCancellation handles CLI execution with cancellation +func executeForegroundJobCLIWithCancellation(ctx context.Context, jobID string, recipe *Recipe, recipePath string) (string, error) { + log.Printf("Executing job %s via CLI fallback using recipe file: %s", jobID, recipePath) + // Find the goose CLI binary + goosePath, err := findGooseBinary() + if err != nil { + return "", fmt.Errorf("failed to find goose CLI binary: %w", err) + } + + // Generate session name for this scheduled job + sessionName := fmt.Sprintf("scheduled-%s", jobID) + // Create command with context for cancellation + cmd := exec.CommandContext(ctx, goosePath, "run", + "--recipe", recipePath, + "--name", sessionName, + "--scheduled-job-id", jobID, + ) + + // Set up process group for proper cleanup + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, // Create new process group + } + + // Set up environment + cmd.Env = append(os.Environ(), + fmt.Sprintf("GOOSE_JOB_ID=%s", jobID), + ) + + log.Printf("Starting foreground CLI job %s with session %s", jobID, sessionName) + + // Start the process + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("failed to start foreground CLI execution: %w", err) + } + + // Register the process with the process manager + _, cancel := context.WithCancel(ctx) + globalProcessManager.AddProcess(jobID, cmd.Process, cancel) + + // Ensure cleanup + defer func() { + globalProcessManager.RemoveProcess(jobID) + cancel() + }() + + // Wait for completion or cancellation + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + + select { + case <-ctx.Done(): + // Context cancelled - kill the process + log.Printf("Foreground CLI job %s cancelled, killing process", jobID) + globalProcessManager.KillProcess(jobID) + return "", ctx.Err() + case err := <-done: + if err != nil { + log.Printf("Foreground CLI job %s failed: %v", jobID, err) + return "", fmt.Errorf("foreground CLI execution failed: %w", err) + } + log.Printf("Foreground CLI job %s completed successfully with session %s", jobID, sessionName) + return sessionName, nil + } +} + +// findGooseBinary locates the goose CLI binary +func findGooseBinary() (string, error) { + // Try different possible locations + possiblePaths := []string{ + "goose", // In PATH + "./goose", // Current directory + "../goose", // Parent directory + } + + // Also try relative to the current executable + if exePath, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exePath) + possiblePaths = append(possiblePaths, + filepath.Join(exeDir, "goose"), + filepath.Join(exeDir, "..", "goose"), + ) + } + + for _, path := range possiblePaths { + if _, err := exec.LookPath(path); err == nil { + return path, nil + } + // Also check if file exists directly + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + + return "", fmt.Errorf("goose CLI binary not found in any of: %v", possiblePaths) +} + +// isDesktopAppRunning checks if the Goose desktop app is currently running +func isDesktopAppRunning() bool { + log.Println("Checking if desktop app is running...") + + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("pgrep", "-f", "Goose.app") + case "windows": + cmd = exec.Command("tasklist", "/FI", "IMAGENAME eq Goose.exe") + case "linux": + cmd = exec.Command("pgrep", "-f", "goose") + default: + log.Printf("Unsupported OS: %s", runtime.GOOS) + return false + } + + output, err := cmd.Output() + if err != nil { + log.Printf("Failed to check if desktop app is running: %v", err) + return false + } + + var isRunning bool + switch runtime.GOOS { + case "darwin", "linux": + isRunning = len(output) > 0 + case "windows": + isRunning = strings.Contains(string(output), "Goose.exe") + } + + log.Printf("Desktop app running: %v", isRunning) + return isRunning +} + +// parseRecipeFile parses a recipe file (YAML or JSON) +func parseRecipeFile(recipePath string) (*Recipe, error) { + content, err := os.ReadFile(recipePath) + if err != nil { + return nil, err + } + + 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 +} + +// generateDeepLink creates a deep link for the recipe with session name +func generateDeepLink(recipe *Recipe, jobID, sessionName string) (string, error) { + // Create the recipe config for the deep link + recipeConfig := map[string]interface{}{ + "id": jobID, + "title": recipe.Title, + "description": recipe.Description, + "instructions": recipe.Instructions, + "activities": []string{}, // Empty activities array + "prompt": recipe.Prompt, + "sessionName": sessionName, // Include session name for proper tracking + } + + // Encode the config as JSON then base64 + configJSON, err := json.Marshal(recipeConfig) + if err != nil { + return "", err + } + + configBase64 := base64.StdEncoding.EncodeToString(configJSON) + + // Create the deep link URL with scheduled job ID parameter + deepLink := fmt.Sprintf("goose://recipe?config=%s&scheduledJob=%s", configBase64, jobID) + + log.Printf("Generated deep link for job %s with session %s (length: %d)", jobID, sessionName, len(deepLink)) + return deepLink, nil +} + +// openDeepLink opens a deep link using the system's default protocol handler +func openDeepLink(deepLink string) error { + log.Printf("Opening deep link: %s", deepLink) + + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", deepLink) + case "windows": + cmd = exec.Command("cmd", "/c", "start", "", deepLink) + case "linux": + cmd = exec.Command("xdg-open", deepLink) + default: + return fmt.Errorf("unsupported OS: %s", runtime.GOOS) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to open deep link: %w", err) + } + + log.Println("Deep link opened successfully") + return nil +} + +// waitForSessionCompletionWithCancellation polls for session completion with cancellation support +func waitForSessionCompletionWithCancellation(ctx context.Context, sessionName string, timeout time.Duration) error { + log.Printf("Waiting for session %s to complete (timeout: %v)", sessionName, timeout) + + start := time.Now() + ticker := time.NewTicker(10 * time.Second) // Check every 10 seconds + defer ticker.Stop() + + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for { + select { + case <-timeoutCtx.Done(): + if timeoutCtx.Err() == context.DeadlineExceeded { + return fmt.Errorf("session %s timed out after %v", sessionName, timeout) + } + return timeoutCtx.Err() // Cancelled + case <-ticker.C: + elapsed := time.Since(start) + log.Printf("Checking session %s status (elapsed: %v)", sessionName, elapsed) + + // Check if session exists and is complete + complete, err := isSessionComplete(sessionName) + if err != nil { + log.Printf("Error checking session %s status: %v", sessionName, err) + // Continue polling - session might not be created yet + continue + } + + if complete { + log.Printf("Session %s completed after %v", sessionName, elapsed) + return nil + } + + log.Printf("Session %s still running (elapsed: %v)", sessionName, elapsed) + } + } +} + +// isSessionComplete checks if a session is complete by querying the Goose sessions API +func isSessionComplete(sessionName string) (bool, error) { + // Try to find the goose CLI binary to query session status + goosePath, err := findGooseBinary() + if err != nil { + return false, fmt.Errorf("failed to find goose CLI binary: %w", err) + } + + // Use goose CLI to list sessions and check if our session exists and is complete + cmd := exec.Command(goosePath, "sessions", "list", "--format", "json") + + output, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("failed to list sessions: %w", err) + } + + // Parse the JSON output to find our session + var sessions []map[string]interface{} + if err := json.Unmarshal(output, &sessions); err != nil { + return false, fmt.Errorf("failed to parse sessions JSON: %w", err) + } + + // Look for our session by name + for _, session := range sessions { + if name, ok := session["name"].(string); ok && name == sessionName { + // Session exists, check if it's complete + // A session is considered complete if it's not currently active + // We can check this by looking for an "active" field or similar + if active, ok := session["active"].(bool); ok { + return !active, nil // Complete if not active + } + + // If no active field, check for completion indicators + // This might vary based on the actual Goose CLI output format + if status, ok := session["status"].(string); ok { + return status == "completed" || status == "finished" || status == "done", nil + } + + // If we found the session but can't determine status, assume it's still running + return false, nil + } + } + + // Session not found - it might not be created yet, so not complete + return false, nil +} + +// isForegroundJob checks if a recipe is configured for foreground execution +func isForegroundJob(recipePath string) bool { + // Simple struct to just check the schedule.foreground field + type ScheduleConfig struct { + Foreground bool `json:"foreground" yaml:"foreground"` + } + type MinimalRecipe struct { + Schedule *ScheduleConfig `json:"schedule" yaml:"schedule"` + } + + content, err := os.ReadFile(recipePath) + if err != nil { + return false // Default to background if we can't read + } + + var recipe MinimalRecipe + + // Try YAML first, then JSON + if err := yaml.Unmarshal(content, &recipe); err != nil { + if err := json.Unmarshal(content, &recipe); err != nil { + return false // Default to background if we can't parse + } + } + + return recipe.Schedule != nil && recipe.Schedule.Foreground +} \ No newline at end of file diff --git a/temporal-service/main.go b/temporal-service/main.go index a17dddc70943..22b92747cc25 100644 --- a/temporal-service/main.go +++ b/temporal-service/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "fmt" "log" "net" @@ -11,17 +10,14 @@ import ( "os/exec" "os/signal" "path/filepath" + "runtime" "strconv" "strings" "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 ( @@ -36,6 +32,48 @@ 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++ { @@ -55,12 +93,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 != "" { @@ -68,13 +106,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, @@ -82,92 +120,6 @@ 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 @@ -179,26 +131,36 @@ 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 { @@ -211,512 +173,128 @@ 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...") possiblePaths := []string{ - "./temporal", // Current directory + "./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, + 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"), - ) + } + possiblePaths = append(possiblePaths, additionalPaths...) + log.Printf("Will check these additional paths: %v", additionalPaths) + } else { + log.Printf("Failed to get executable path: %v", err) } - + + log.Printf("Checking %d possible paths for temporal CLI", len(possiblePaths)) + // Test each possible path - for _, path := range possiblePaths { + for i, path := range possiblePaths { + log.Printf("Checking path %d/%d: %s", i+1, len(possiblePaths), path) if _, err := os.Stat(path); err == nil { + log.Printf("File exists at: %s", path) // File exists, test if it's executable and the right binary 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("File does not exist at %s: %v", path, err) } } - - 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) - } - - 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) - } - - // 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), - 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 -} - -// 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) - - 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 - } - - 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 { - 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) - } - sessionID := strings.TrimSpace(string(output)) - logger.Info("Recipe executed successfully", "jobID", jobID, "sessionID", sessionID) - return sessionID, nil -} - -// 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 "list": - resp = ts.listSchedules() - case "run_now": - resp = ts.runNow(req) - default: - resp = JobResponse{Success: false, Message: fmt.Sprintf("Unknown action: %s", req.Action)} - } - - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(resp) + return "", fmt.Errorf("temporal CLI not found in PATH or any of the expected locations: %v", possiblePaths) } -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 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)} - } - - 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"} -} +// 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...") -func (ts *TemporalService) deleteSchedule(req JobRequest) JobResponse { - if req.JobID == "" { - return JobResponse{Success: false, Message: "Missing job_id"} + // 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 } - scheduleID := fmt.Sprintf("goose-job-%s", req.JobID) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() + log.Printf("Temporal server not running, attempting to start it on port %d...", ports.TemporalPort) - handle := ts.client.ScheduleClient().GetHandle(ctx, scheduleID) - err := handle.Delete(ctx) + // Find the temporal CLI binary + temporalCmd, err := findTemporalCLI() 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"} + log.Printf("ERROR: Could not find temporal CLI: %v", err) + return fmt.Errorf("could not find temporal CLI: %w", err) } - scheduleID := fmt.Sprintf("goose-job-%s", req.JobID) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() + log.Printf("Using Temporal CLI at: %s", temporalCmd) - 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)} - } + // 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"} - // Update in memory - if job, exists := ts.scheduleJobs[req.JobID]; exists { - job.Paused = true - } + log.Printf("Starting Temporal server with command: %s %v", temporalCmd, args) - log.Printf("Paused schedule for job: %s", req.JobID) - return JobResponse{Success: true, Message: "Schedule paused successfully"} -} + cmd := exec.Command(temporalCmd, args...) -func (ts *TemporalService) unpauseSchedule(req JobRequest) JobResponse { - if req.JobID == "" { - return JobResponse{Success: false, Message: "Missing job_id"} + // Properly detach the process so it survives when the parent exits + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, // Create new process group + Pgid: 0, // Use process ID as group ID } - scheduleID := fmt.Sprintf("goose-job-%s", req.JobID) + // Redirect stdin/stdout/stderr to avoid hanging + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil - 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)} + // 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) } - 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 - } - } + log.Printf("Temporal server started with PID: %d (port: %d, UI port: %d)", + cmd.Process.Pid, ports.TemporalPort, ports.UIPort) - // Update with Temporal schedule info - if len(schedule.Spec.CronExpressions) > 0 { - jobStatus.CronExpr = schedule.Spec.CronExpressions[0] - } + // 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() - // 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 + 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 } else { - log.Printf("Warning: Could not get detailed info for schedule %s: %v", schedule.ID, err) + log.Printf("Temporal server not ready yet (attempt %d)", attemptCount) } - - // 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() { @@ -755,6 +333,9 @@ 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() @@ -774,4 +355,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/process_manager.go b/temporal-service/process_manager.go new file mode 100644 index 000000000000..7245c57a56ca --- /dev/null +++ b/temporal-service/process_manager.go @@ -0,0 +1,287 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +// ProcessManager tracks and manages spawned processes +type ProcessManager struct { + processes map[string]*ManagedProcess + mutex sync.RWMutex +} + +// ManagedProcess represents a process being managed by the ProcessManager +type ManagedProcess struct { + JobID string + Process *os.Process + Cancel context.CancelFunc + StartTime time.Time +} + +// Global process manager instance +var globalProcessManager = &ProcessManager{ + processes: make(map[string]*ManagedProcess), +} + +// AddProcess adds a process to be managed +func (pm *ProcessManager) AddProcess(jobID string, process *os.Process, cancel context.CancelFunc) { + pm.mutex.Lock() + defer pm.mutex.Unlock() + + pm.processes[jobID] = &ManagedProcess{ + JobID: jobID, + Process: process, + Cancel: cancel, + StartTime: time.Now(), + } + log.Printf("Added process %d for job %s to process manager", process.Pid, jobID) +} + +// RemoveProcess removes a process from management +func (pm *ProcessManager) RemoveProcess(jobID string) { + pm.mutex.Lock() + defer pm.mutex.Unlock() + + if mp, exists := pm.processes[jobID]; exists { + log.Printf("Removed process %d for job %s from process manager", mp.Process.Pid, jobID) + delete(pm.processes, jobID) + } +} + +// KillProcess kills a specific process and its children +func (pm *ProcessManager) KillProcess(jobID string) error { + pm.mutex.Lock() + defer pm.mutex.Unlock() + + mp, exists := pm.processes[jobID] + if !exists { + return fmt.Errorf("no process found for job %s", jobID) + } + + log.Printf("Killing process %d for job %s", mp.Process.Pid, jobID) + + // Cancel the context first + if mp.Cancel != nil { + mp.Cancel() + } + + // Kill the process and its children + if err := killProcessGroup(mp.Process); err != nil { + log.Printf("Error killing process group for job %s: %v", jobID, err) + return err + } + + delete(pm.processes, jobID) + return nil +} + +// KillAllProcesses kills all managed processes +func (pm *ProcessManager) KillAllProcesses() { + pm.mutex.Lock() + defer pm.mutex.Unlock() + + log.Printf("Killing all %d managed processes", len(pm.processes)) + + for jobID, mp := range pm.processes { + log.Printf("Killing process %d for job %s", mp.Process.Pid, jobID) + + if mp.Cancel != nil { + mp.Cancel() + } + + if err := killProcessGroup(mp.Process); err != nil { + log.Printf("Error killing process group for job %s: %v", jobID, err) + } + } + + pm.processes = make(map[string]*ManagedProcess) +} + +// ListProcesses returns a copy of the current process map +func (pm *ProcessManager) ListProcesses() map[string]*ManagedProcess { + pm.mutex.RLock() + defer pm.mutex.RUnlock() + + result := make(map[string]*ManagedProcess) + for k, v := range pm.processes { + result[k] = v + } + return result +} + +// killProcessGroup kills a process and all its children +func killProcessGroup(process *os.Process) error { + if process == nil { + return nil + } + + pid := process.Pid + log.Printf("Attempting to kill process group for PID %d", pid) + + switch runtime.GOOS { + case "windows": + // On Windows, kill the process tree + cmd := exec.Command("taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", pid)) + if err := cmd.Run(); err != nil { + log.Printf("Failed to kill Windows process tree for PID %d: %v", pid, err) + return err + } + log.Printf("Successfully killed Windows process tree for PID %d", pid) + return nil + default: + // On Unix-like systems, kill the process group more aggressively + log.Printf("Killing Unix process group for PID %d", pid) + + // First, try to kill the entire process group with SIGTERM + if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil { + log.Printf("Failed to send SIGTERM to process group -%d: %v", pid, err) + } else { + log.Printf("Sent SIGTERM to process group -%d", pid) + } + + // Also try to kill the main process directly + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + log.Printf("Failed to send SIGTERM to process %d: %v", pid, err) + } else { + log.Printf("Sent SIGTERM to process %d", pid) + } + + // Give processes a brief moment to terminate gracefully + time.Sleep(1 * time.Second) + + // Force kill the process group with SIGKILL + if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { + log.Printf("Failed to send SIGKILL to process group -%d: %v", pid, err) + } else { + log.Printf("Sent SIGKILL to process group -%d", pid) + } + + // Force kill the main process with SIGKILL + if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { + log.Printf("Failed to send SIGKILL to process %d: %v", pid, err) + } else { + log.Printf("Sent SIGKILL to process %d", pid) + } + + // Also try using the process.Kill() method as a fallback + if err := process.Kill(); err != nil { + log.Printf("Failed to kill process using process.Kill(): %v", err) + } else { + log.Printf("Successfully killed process using process.Kill()") + } + + log.Printf("Completed kill attempts for process group %d", pid) + return nil + } +} + +// FindAndKillProcessesByPattern finds and kills processes related to a job by searching for patterns +func FindAndKillProcessesByPattern(jobID string) int { + log.Printf("Searching for additional processes to kill for job %s", jobID) + + killedCount := 0 + + switch runtime.GOOS { + case "darwin", "linux": + // Search for goose processes that might be related to this job + patterns := []string{ + fmt.Sprintf("scheduled-%s", jobID), // Session name pattern + fmt.Sprintf("GOOSE_JOB_ID=%s", jobID), // Environment variable pattern + jobID, // Job ID itself + } + + for _, pattern := range patterns { + // Use pgrep to find processes + cmd := exec.Command("pgrep", "-f", pattern) + output, err := cmd.Output() + if err != nil { + log.Printf("No processes found for pattern '%s': %v", pattern, err) + continue + } + + pidStr := strings.TrimSpace(string(output)) + if pidStr == "" { + continue + } + + pids := strings.Split(pidStr, "\n") + for _, pidStr := range pids { + if pidStr == "" { + continue + } + + pid, err := strconv.Atoi(pidStr) + if err != nil { + log.Printf("Invalid PID '%s': %v", pidStr, err) + continue + } + + log.Printf("Found process %d matching pattern '%s' for job %s", pid, pattern, jobID) + + // Kill the process + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + log.Printf("Failed to send SIGTERM to PID %d: %v", pid, err) + } else { + log.Printf("Sent SIGTERM to PID %d", pid) + killedCount++ + } + + // Wait a moment then force kill + time.Sleep(500 * time.Millisecond) + if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { + log.Printf("Failed to send SIGKILL to PID %d: %v", pid, err) + } else { + log.Printf("Sent SIGKILL to PID %d", pid) + } + } + } + + case "windows": + // On Windows, search for goose.exe processes + sessionPattern := fmt.Sprintf("scheduled-%s", jobID) + + // Use tasklist to find processes + cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq goose.exe", "/FO", "CSV") + output, err := cmd.Output() + if err != nil { + log.Printf("Failed to list Windows processes: %v", err) + return killedCount + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, sessionPattern) || strings.Contains(line, jobID) { + // Extract PID from CSV format + fields := strings.Split(line, ",") + if len(fields) >= 2 { + pidStr := strings.Trim(fields[1], "\"") + if pid, err := strconv.Atoi(pidStr); err == nil { + log.Printf("Found Windows process %d for job %s", pid, jobID) + + // Kill the process + killCmd := exec.Command("taskkill", "/F", "/PID", fmt.Sprintf("%d", pid)) + if err := killCmd.Run(); err != nil { + log.Printf("Failed to kill Windows process %d: %v", pid, err) + } else { + log.Printf("Killed Windows process %d", pid) + killedCount++ + } + } + } + } + } + } + + log.Printf("Killed %d additional processes for job %s", killedCount, jobID) + return killedCount +} \ No newline at end of file diff --git a/temporal-service/schedule.go b/temporal-service/schedule.go new file mode 100644 index 000000000000..e3191988c975 --- /dev/null +++ b/temporal-service/schedule.go @@ -0,0 +1,716 @@ +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 new file mode 100644 index 000000000000..b87d45f5857d --- /dev/null +++ b/temporal-service/service.go @@ -0,0 +1,283 @@ +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 b0ea4cd08ea5..199d8f530029 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 cae672126d9d..778c134ef7c0 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1085,6 +1085,10 @@ "cron": { "type": "string" }, + "execution_mode": { + "type": "string", + "nullable": true + }, "id": { "type": "string" }, @@ -1901,6 +1905,10 @@ "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 8618bace673e..2d5f4845d138 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -15,9 +15,10 @@ "start:test-error": "GOOSE_TEST_ERROR=true electron-forge start", "package": "electron-forge package", "make": "electron-forge make", - "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", + "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", "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 new file mode 100644 index 000000000000..60a391bad792 --- /dev/null +++ b/ui/desktop/scripts/prepare-platform-binaries.js @@ -0,0 +1,155 @@ +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 424a469be070..eacead959c59 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -64,6 +64,7 @@ export type ContextManageResponse = { export type CreateScheduleRequest = { cron: string; + execution_mode?: string | null; id: string; recipe_source: string; }; @@ -316,6 +317,7 @@ 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 deleted file mode 100755 index 8748b44db8c3..000000000000 --- a/ui/desktop/src/bin/jbang +++ /dev/null @@ -1,89 +0,0 @@ -#!/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 jbang 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." - -# Initialize hermit -log "Initializing hermit." -hermit init >> "$LOG_FILE" - -# Install OpenJDK using hermit -log "Installing OpenJDK with hermit." -hermit install openjdk@17 >> "$LOG_FILE" - -# Download and install jbang if not present -if [ ! -f ~/.config/goose/mcp-hermit/bin/jbang ]; then - log "Downloading and installing jbang." - curl -Ls https://sh.jbang.dev | bash -s - app setup - cp ~/.jbang/bin/jbang ~/.config/goose/mcp-hermit/bin/ -fi - -# Verify installations -log "Verifying installation locations:" -log "hermit: $(which hermit)" -log "java: $(which java)" -log "jbang: $(which jbang)" - -# Check for custom registry settings -log "Checking for GOOSE_JBANG_REGISTRY environment variable for custom jbang registry setup..." -if [ -n "${GOOSE_JBANG_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_JBANG_REGISTRY" > /dev/null; then - log "Checking custom goose registry availability: $GOOSE_JBANG_REGISTRY" - log "$GOOSE_JBANG_REGISTRY is accessible. Setting it as JBANG_REPO." - export JBANG_REPO="$GOOSE_JBANG_REGISTRY" -else - log "GOOSE_JBANG_REGISTRY is not set or not accessible. Using default jbang repository." -fi - -# Trust all jbang scripts that a user might install. Without this, Jbang will attempt to -# prompt the user to trust each script. However, Goose does not surfact this modal and without -# user input, the addExtension method will timeout and fail. -jbang --quiet trust add * - -# Final step: Execute jbang with passed arguments, always including --fresh and --quiet -log "Executing 'jbang' command with arguments: $*" -jbang --fresh --quiet "$@" || log "Failed to execute 'jbang' with arguments: $*" - -log "jbang setup script completed successfully." \ No newline at end of file diff --git a/ui/desktop/src/bin/npx b/ui/desktop/src/bin/npx deleted file mode 100755 index e3faa03b3138..000000000000 --- a/ui/desktop/src/bin/npx +++ /dev/null @@ -1,105 +0,0 @@ -#!/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 deleted file mode 100755 index 8a1eec121345..000000000000 --- a/ui/desktop/src/bin/uvx +++ /dev/null @@ -1,89 +0,0 @@ -#!/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 412575e1a5ad..6d674ed4d4da 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -1,4 +1,12 @@ -import React, { useEffect, useRef, useState, useMemo, useCallback, createContext, useContext } from 'react'; +import React, { + useEffect, + useRef, + useState, + useMemo, + useCallback, + createContext, + useContext, +} from 'react'; import { getApiUrl } from '../config'; import FlappyGoose from './FlappyGoose'; import GooseMessage from './GooseMessage'; @@ -100,6 +108,7 @@ function ChatContent({ const [sessionTokenCount, setSessionTokenCount] = useState(0); const [ancestorMessages, setAncestorMessages] = useState([]); const [droppedFiles, setDroppedFiles] = useState([]); + const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false); const scrollRef = useRef(null); @@ -119,6 +128,8 @@ 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; @@ -152,7 +163,11 @@ function ChatContent({ } = useMessageStream({ api: getApiUrl('/reply'), initialMessages: chat.messages, - body: { session_id: chat.id, session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR') }, + body: { + session_id: chat.id, + session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'), + ...(recipeConfig?.scheduledJobId && { scheduled_job_id: recipeConfig.scheduledJobId }), + }, onFinish: async (_message, _reason) => { window.electron.stopPowerSaveBlocker(); @@ -297,6 +312,40 @@ 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(); @@ -512,148 +561,148 @@ function ChatContent({ return (
- {/* Loader when generating recipe */} - {isGeneratingRecipe && } - - - - {recipeConfig?.title && messages.length > 0 && ( - { - // Handle profile change - console.log('Change profile clicked'); - }} - /> - )} - {messages.length === 0 ? ( - - ) : ( - - - {filteredMessages.map((message, index) => ( -
- {isUserMessage(message) ? ( - <> - {hasContextHandlerContent(message) ? ( - - ) : ( - - )} - - ) : ( - <> - {/* Only render GooseMessage if it's not a message invoking some context management */} - {hasContextHandlerContent(message) ? ( - - ) : ( - { - const updatedMessages = [...messages, newMessage]; - setMessages(updatedMessages); - }} - toolCallNotifications={toolCallNotifications} - /> - )} - - )} -
- ))} -
- - {error && ( -
-
- {error.message || 'Honk! Goose experienced an error while responding'} + {/* Loader when generating recipe */} + {isGeneratingRecipe && } + + + + {recipeConfig?.title && messages.length > 0 && ( + { + // Handle profile change + console.log('Change profile clicked'); + }} + /> + )} + {messages.length === 0 ? ( + + ) : ( + + + {filteredMessages.map((message, index) => ( +
+ {isUserMessage(message) ? ( + <> + {hasContextHandlerContent(message) ? ( + + ) : ( + + )} + + ) : ( + <> + {/* Only render GooseMessage if it's not a message invoking some context management */} + {hasContextHandlerContent(message) ? ( + + ) : ( + { + const updatedMessages = [...messages, newMessage]; + setMessages(updatedMessages); + }} + toolCallNotifications={toolCallNotifications} + /> + )} + + )} +
+ ))} +
+ + {error && ( +
+
+ {error.message || 'Honk! Goose experienced an error while responding'} +
+
{ + // Find the last user message + const lastUserMessage = messages.reduceRight( + (found, m) => found || (m.role === 'user' ? m : null), + null as Message | null + ); + if (lastUserMessage) { + append(lastUserMessage); + } + }} + > + Retry Last Message +
-
{ - // Find the last user message - const lastUserMessage = messages.reduceRight( - (found, m) => found || (m.role === 'user' ? m : null), - null as Message | null - ); - if (lastUserMessage) { - append(lastUserMessage); - } - }} - > - Retry Last Message -
-
- )} -
- - )} - -
- {isLoading && } - -
- - - {showGame && setShowGame(false)} />} - - { - updateSummary(editedContent); - closeSummaryModal(); - }} - summaryContent={summaryContent} - /> -
+ )} +
+ + )} + +
+ {isLoading && } + +
+ + + {showGame && setShowGame(false)} />} + + { + updateSummary(editedContent); + closeSummaryModal(); + }} + summaryContent={summaryContent} + /> +
); } diff --git a/ui/desktop/src/components/RecipeEditor.tsx b/ui/desktop/src/components/RecipeEditor.tsx index ebab6469c353..e062357fbd76 100644 --- a/ui/desktop/src/components/RecipeEditor.tsx +++ b/ui/desktop/src/components/RecipeEditor.tsx @@ -332,15 +332,13 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
{/* Action Buttons */}
- {process.env.ALPHA && ( - - )} +
)} @@ -617,6 +671,50 @@ 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') && (