From d50fc762abf31ab2add2b6d5673b635b4de1d432 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 29 Jul 2026 00:33:04 -0700 Subject: [PATCH] feat(dispatch): add detached SSH task execution --- .github/workflows/cli-package.yml | 21 + .github/workflows/desktop-package.yml | 5 + .github/workflows/nightly.yml | 58 + scripts/relay/package-contract.test.mjs | 14 + src/apps/cli/src/agent/runtime_client.rs | 61 +- src/apps/cli/src/dispatch/mod.rs | 788 ++++++++ src/apps/cli/src/dispatch/permissions.rs | 54 + src/apps/cli/src/dispatch/protocol.rs | 263 +++ src/apps/cli/src/dispatch/runner.rs | 377 ++++ src/apps/cli/src/dispatch/store.rs | 1621 +++++++++++++++++ src/apps/cli/src/dispatch/worker.rs | 497 +++++ src/apps/cli/src/main.rs | 77 +- src/apps/cli/src/peer_host/deny.rs | 31 + src/apps/cli/src/root_handlers.rs | 45 +- src/apps/cli/src/runtime/approval.rs | 67 +- src/apps/desktop/src/api/dispatch_api.rs | 158 ++ src/apps/desktop/src/api/mod.rs | 1 + src/apps/desktop/src/api/peer_host_invoke.rs | 10 + .../src/api/remote_workspace_policy.rs | 29 + src/apps/desktop/src/lib.rs | 10 + src/apps/server/Cargo.toml | 2 +- src/apps/server/src/main.rs | 33 + src/apps/server/src/routes/dispatch.rs | 183 ++ .../server/src/routes/external_sources.rs | 1 + src/apps/server/src/routes/mod.rs | 1 + src/apps/server/src/routes/websocket.rs | 4 + .../core/src/runtime_ownership_tests.rs | 47 + .../core/src/service/dispatch/controller.rs | 505 +++++ .../assembly/core/src/service/dispatch/mod.rs | 409 +++++ .../core/src/service/dispatch/target.rs | 109 ++ src/crates/assembly/core/src/service/mod.rs | 1 + .../core/src/service/remote_ssh/mod.rs | 4 +- .../src/remote_ssh/disabled.rs | 123 ++ .../src/remote_ssh/dispatch_ssh.rs | 1607 ++++++++++++++++ .../src/remote_ssh/mod.rs | 6 +- .../src/remote_ssh/relay_deploy.rs | 93 +- .../src/remote_ssh/release_verify.rs | 136 ++ .../sections/sessions/SessionsSection.scss | 33 + .../sections/sessions/SessionsSection.tsx | 65 +- .../dispatch/DispatchInstallDialog.scss | 208 +++ .../dispatch/DispatchInstallDialog.test.tsx | 268 +++ .../dispatch/DispatchInstallDialog.tsx | 460 +++++ .../dispatch/DispatchJobObserver.test.ts | 677 +++++++ .../features/dispatch/DispatchJobObserver.ts | 497 +++++ .../dispatch/DispatchTargetPicker.scss | 218 +++ .../dispatch/DispatchTargetPicker.tsx | 243 +++ src/web-ui/src/features/dispatch/README.md | 33 + .../dispatch/dispatch.contract.test.ts | 69 + .../src/features/dispatch/dispatchApi.test.ts | 26 + .../src/features/dispatch/dispatchApi.ts | 89 + .../dispatch/dispatchJobStore.test.ts | 135 ++ .../src/features/dispatch/dispatchJobStore.ts | 340 ++++ .../dispatch/dispatchNavPresentation.test.ts | 38 + .../dispatch/dispatchNavPresentation.ts | 37 + .../features/dispatch/dispatchPreflight.ts | 24 + src/web-ui/src/features/dispatch/types.ts | 189 ++ .../features/dispatch/useDispatchTargets.ts | 40 + .../src/flow_chat/components/ChatInput.tsx | 111 +- .../components/ChatInputWorkspaceStrip.tsx | 21 +- .../ChatInputWorkspaceStripLayout.test.ts | 32 + .../src/flow_chat/hooks/useMessageSender.ts | 5 + .../services/AgenticEventListener.ts | 111 ++ .../services/FlowChatManager.test.ts | 4 + .../src/flow_chat/services/FlowChatManager.ts | 7 + .../flow-chat-manager/MessageModule.test.ts | 143 +- .../flow-chat-manager/MessageModule.ts | 79 +- .../flow-chat-manager/PersistenceModule.ts | 12 +- .../flow-chat-manager/SessionModule.test.ts | 95 + .../flow-chat-manager/SessionModule.ts | 132 +- .../src/flow_chat/store/FlowChatStore.test.ts | 144 ++ .../src/flow_chat/store/FlowChatStore.ts | 186 +- src/web-ui/src/flow_chat/types/flow-chat.ts | 16 + .../flow_chat/utils/dialogTurnStability.ts | 82 + .../api/adapters/peer-device-adapter.ts | 10 + src/web-ui/src/locales/en-US/common.json | 56 + src/web-ui/src/locales/en-US/flow-chat.json | 20 + src/web-ui/src/locales/zh-CN/common.json | 56 + src/web-ui/src/locales/zh-CN/flow-chat.json | 20 + src/web-ui/src/locales/zh-TW/common.json | 56 + src/web-ui/src/locales/zh-TW/flow-chat.json | 20 + 80 files changed, 12416 insertions(+), 142 deletions(-) create mode 100644 src/apps/cli/src/dispatch/mod.rs create mode 100644 src/apps/cli/src/dispatch/permissions.rs create mode 100644 src/apps/cli/src/dispatch/protocol.rs create mode 100644 src/apps/cli/src/dispatch/runner.rs create mode 100644 src/apps/cli/src/dispatch/store.rs create mode 100644 src/apps/cli/src/dispatch/worker.rs create mode 100644 src/apps/desktop/src/api/dispatch_api.rs create mode 100644 src/apps/server/src/routes/dispatch.rs create mode 100644 src/crates/assembly/core/src/service/dispatch/controller.rs create mode 100644 src/crates/assembly/core/src/service/dispatch/mod.rs create mode 100644 src/crates/assembly/core/src/service/dispatch/target.rs create mode 100644 src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs create mode 100644 src/crates/services/services-integrations/src/remote_ssh/release_verify.rs create mode 100644 src/web-ui/src/features/dispatch/DispatchInstallDialog.scss create mode 100644 src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx create mode 100644 src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx create mode 100644 src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts create mode 100644 src/web-ui/src/features/dispatch/DispatchJobObserver.ts create mode 100644 src/web-ui/src/features/dispatch/DispatchTargetPicker.scss create mode 100644 src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx create mode 100644 src/web-ui/src/features/dispatch/README.md create mode 100644 src/web-ui/src/features/dispatch/dispatch.contract.test.ts create mode 100644 src/web-ui/src/features/dispatch/dispatchApi.test.ts create mode 100644 src/web-ui/src/features/dispatch/dispatchApi.ts create mode 100644 src/web-ui/src/features/dispatch/dispatchJobStore.test.ts create mode 100644 src/web-ui/src/features/dispatch/dispatchJobStore.ts create mode 100644 src/web-ui/src/features/dispatch/dispatchNavPresentation.test.ts create mode 100644 src/web-ui/src/features/dispatch/dispatchNavPresentation.ts create mode 100644 src/web-ui/src/features/dispatch/dispatchPreflight.ts create mode 100644 src/web-ui/src/features/dispatch/types.ts create mode 100644 src/web-ui/src/features/dispatch/useDispatchTargets.ts diff --git a/.github/workflows/cli-package.yml b/.github/workflows/cli-package.yml index 0e88d39529..10f37add26 100644 --- a/.github/workflows/cli-package.yml +++ b/.github/workflows/cli-package.yml @@ -119,6 +119,10 @@ jobs: - name: Build CLI (Unix) if: runner.os != 'Windows' shell: bash + env: + # Keep the CLI self-updater and SSH installer on the same embedded + # minisign trust root used by the Desktop updater. + BITFUN_RELEASE_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} run: | set -euo pipefail cargo build --release \ @@ -168,6 +172,19 @@ jobs: run: | ./scripts/cli/package-windows.ps1 -Version $env:VERSION -Target $env:TARGET + - name: Sign CLI archive and checksum (macOS) + if: runner.os == 'macOS' + shell: bash + env: + BITFUN_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + BITFUN_SIGNING_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + BITFUN_SIGNING_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: | + set -euo pipefail + bash scripts/sign-release-assets.sh \ + "${{ steps.stage.outputs.archive }}" \ + "${{ steps.stage.outputs.checksum }}" + - name: Upload artifact uses: actions/upload-artifact@v6 with: @@ -176,6 +193,8 @@ jobs: path: | ${{ steps.stage.outputs.archive || steps.stage-windows.outputs.archive }} ${{ steps.stage.outputs.checksum || steps.stage-windows.outputs.checksum }} + bitfun-cli-*.tar.gz.sig + bitfun-cli-*.tar.gz.sha256.sig # ── Aggregate and upload to GitHub Release ───────────────────────── upload-release-assets: @@ -227,6 +246,8 @@ jobs: files: | cli-release-assets/bitfun-cli-*.tar.gz cli-release-assets/bitfun-cli-*.tar.gz.sha256 + cli-release-assets/bitfun-cli-*.tar.gz.sig + cli-release-assets/bitfun-cli-*.tar.gz.sha256.sig cli-release-assets/bitfun-cli-*.zip cli-release-assets/bitfun-cli-*.zip.sha256 cli-release-assets/SHA256SUMS diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index e109533cbb..9d79add20c 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -373,6 +373,7 @@ jobs: linux-release-assets/bitfun-relay-server-*.tar.gz linux-release-assets/bitfun-relay-server-*.tar.gz.sha256 linux-release-assets/*.tar.gz.sig + linux-release-assets/*.tar.gz.sha256.sig linux-release-assets/linux-binaries.json fail_on_unmatched_files: true @@ -393,6 +394,10 @@ jobs: "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/linux-binaries.json" \ -o linux-binaries.published.json test "$(jq -r '.version' linux-binaries.published.json)" = "${{ needs.prepare.outputs.version }}" + while IFS= read -r cli_url; do + curl -fsSL --retry 5 --retry-delay 3 "${cli_url}.sig" -o /dev/null + curl -fsSL --retry 5 --retry-delay 3 "${cli_url}.sha256.sig" -o /dev/null + done < <(jq -r '.platforms[].cli.url' linux-binaries.published.json) # Nudge the openbitfun.com mirror to sync now instead of on its next # 10-minute cron tick. Until the mirror has these bytes, CN clients have diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 7c429dd3fc..6bd81dc905 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -208,6 +208,32 @@ jobs: - name: Build desktop app run: ${{ matrix.platform.build_command }} + - name: Package macOS CLI for SSH dispatch + if: runner.os == 'macOS' + id: macos-cli + shell: bash + env: + NIGHTLY_VERSION: ${{ needs.check-changes.outputs.nightly_version }} + TARGET: ${{ matrix.platform.target }} + run: | + set -euo pipefail + ASSET_VERSION="${NIGHTLY_VERSION%%+*}" + cargo build --release --target "$TARGET" -p bitfun-cli + bash scripts/cli/package-unix.sh "$ASSET_VERSION" "$TARGET" + + - name: Sign macOS CLI archive and checksum + if: runner.os == 'macOS' + shell: bash + env: + BITFUN_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + BITFUN_SIGNING_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + BITFUN_SIGNING_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: | + set -euo pipefail + bash scripts/sign-release-assets.sh \ + "${{ steps.macos-cli.outputs.archive }}" \ + "${{ steps.macos-cli.outputs.checksum }}" + - name: Verify AppImage fcitx5 GTK module if: runner.os == 'Linux' shell: bash @@ -224,6 +250,10 @@ jobs: target/release/bundle src/apps/desktop/target/release/bundle BitFun-Installer/src-tauri/target/release/bitfun-installer.exe + bitfun-cli-*-apple-darwin.tar.gz + bitfun-cli-*-apple-darwin.tar.gz.sha256 + bitfun-cli-*-apple-darwin.tar.gz.sig + bitfun-cli-*-apple-darwin.tar.gz.sha256.sig linux-binaries: name: Linux CLI and Relay Server @@ -343,9 +373,37 @@ jobs: release-assets/**/*bitfun-installer.exe release-assets/**/*.sig release-assets/minisign.pub + release-assets/**/bitfun-cli-*-apple-darwin.tar.gz + release-assets/**/bitfun-cli-*-apple-darwin.tar.gz.sha256 linux-release-assets/bitfun-cli-*.tar.gz linux-release-assets/bitfun-cli-*.tar.gz.sha256 linux-release-assets/bitfun-relay-server-*.tar.gz linux-release-assets/bitfun-relay-server-*.tar.gz.sha256 linux-release-assets/*.tar.gz.sig + linux-release-assets/*.tar.gz.sha256.sig linux-release-assets/linux-binaries.json + + - name: Verify published Linux CLI signatures + shell: bash + run: | + set -euo pipefail + while IFS= read -r cli_url; do + curl -fsSL --retry 5 --retry-delay 3 "${cli_url}.sig" -o /dev/null + curl -fsSL --retry 5 --retry-delay 3 "${cli_url}.sha256.sig" -o /dev/null + done < <(jq -r '.platforms[].cli.url' linux-release-assets/linux-binaries.json) + + - name: Verify published macOS CLI assets + shell: bash + env: + NIGHTLY_VERSION: ${{ needs.check-changes.outputs.nightly_version }} + run: | + set -euo pipefail + ASSET_VERSION="${NIGHTLY_VERSION%%+*}" + RELEASE_ROOT="https://github.com/GCWing/BitFun/releases/download/${{ env.NIGHTLY_TAG }}" + for target in aarch64-apple-darwin x86_64-apple-darwin; do + archive="${RELEASE_ROOT}/bitfun-cli-${ASSET_VERSION}-${target}.tar.gz" + curl -fsSL --retry 5 --retry-delay 3 "$archive" -o /dev/null + curl -fsSL --retry 5 --retry-delay 3 "${archive}.sha256" -o /dev/null + curl -fsSL --retry 5 --retry-delay 3 "${archive}.sig" -o /dev/null + curl -fsSL --retry 5 --retry-delay 3 "${archive}.sha256.sig" -o /dev/null + done diff --git a/scripts/relay/package-contract.test.mjs b/scripts/relay/package-contract.test.mjs index e60e9148cf..c2493a2f3d 100644 --- a/scripts/relay/package-contract.test.mjs +++ b/scripts/relay/package-contract.test.mjs @@ -27,6 +27,9 @@ test('formal and nightly releases gate publication on Linux binaries', () => { assert.match(workflow, /needs:\s*\[[^\]]*linux-binaries[^\]]*\]/); assert.match(workflow, /bitfun-relay-server-\*\.tar\.gz/); assert.match(workflow, /bitfun-cli-\*\.tar\.gz/); + assert.match(workflow, /linux-release-assets\/\*\.tar\.gz\.sig/); + assert.match(workflow, /linux-release-assets\/\*\.tar\.gz\.sha256\.sig/); + assert.match(workflow, /\$\{cli_url\}\.sha256\.sig/); assert.match(workflow, /linux-binaries\.json/); } @@ -65,3 +68,14 @@ test('release asset names carry no SemVer build metadata', () => { assert.doesNotMatch(reusable, /package-unix\.sh "\$VERSION"/); assert.match(nightly, /--version "\$\{NIGHTLY_VERSION%%\+\*\}"/); }); + +test('nightly publishes signed macOS CLI archives for SSH dispatch', () => { + const nightly = read('.github/workflows/nightly.yml'); + + assert.match(nightly, /Package macOS CLI for SSH dispatch/); + assert.match(nightly, /scripts\/cli\/package-unix\.sh "\$ASSET_VERSION" "\$TARGET"/); + assert.match(nightly, /steps\.macos-cli\.outputs\.archive/); + assert.match(nightly, /bitfun-cli-\*-apple-darwin\.tar\.gz\.sha256\.sig/); + assert.match(nightly, /for target in aarch64-apple-darwin x86_64-apple-darwin/); + assert.match(nightly, /\$\{archive\}\.sha256\.sig/); +}); diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 3d65f6e6a4..eb2f73cd44 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -18,9 +18,7 @@ use bitfun_agent_runtime::sdk::{ AgentTurnCancellationRequest, AgentTurnSettlementRequest, AgentUserAnswersRequest, PermissionReply, PermissionRequest, PermissionRequestEventReceiver, PortError, PortErrorKind, RuntimeError, SessionTranscript, SessionTranscriptRequest, SessionUsageReport, - AUTO_APPROVE_ASK_CONTEXT_KEY, }; -use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; use bitfun_agent_runtime_ipc::{ RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent, RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcOperation, RuntimeIpcOperationResult, @@ -33,7 +31,7 @@ use bitfun_runtime_ports::{ }; use crate::actions::SHARED_TUI_EMBEDDED_HANDOFF; -use crate::runtime::approval::CliApprovalPolicy; +use crate::runtime::approval::{approval_metadata, CliApprovalPolicy}; use crate::runtime::CliRuntimeContext; fn shared_restore_error(error: RuntimeIpcClientError) -> anyhow::Error { @@ -64,33 +62,6 @@ fn validated_session_summary( }) } -fn cli_approval_metadata( - approval_policy: CliApprovalPolicy, -) -> serde_json::Map { - let mut metadata = serde_json::Map::new(); - if matches!( - approval_policy, - CliApprovalPolicy::Reject | CliApprovalPolicy::Auto - ) { - metadata.insert( - USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), - serde_json::Value::Bool(false), - ); - } - let auto_approve_ask = match approval_policy { - CliApprovalPolicy::Ask => None, - CliApprovalPolicy::DisableAuto | CliApprovalPolicy::Reject => Some(false), - CliApprovalPolicy::Auto => Some(true), - }; - if let Some(auto_approve_ask) = auto_approve_ask { - metadata.insert( - AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), - serde_json::Value::Bool(auto_approve_ask), - ); - } - metadata -} - #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct SessionModeMigrationNotice { pub(crate) previous_mode_id: String, @@ -815,7 +786,7 @@ impl CliAgentRuntimeClient { } // Start the dialog turn; events arrive through the shared broadcast source. - let metadata = cli_approval_metadata(self.approval_policy()); + let metadata = approval_metadata(self.approval_policy()); let request = AgentDialogTurnRequest { session_id: session_id.clone(), message: message.clone(), @@ -1197,17 +1168,13 @@ mod tests { use bitfun_agent_runtime::sdk::{ PermissionDelegationContext, PermissionRequest, PermissionRequestEvent, - PermissionRequestSource, PermissionRequestSourceKind, AUTO_APPROVE_ASK_CONTEXT_KEY, + PermissionRequestSource, PermissionRequestSourceKind, }; - use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; use bitfun_agent_runtime_ipc::{RuntimeIpcClientError, RuntimeIpcError, RuntimeIpcErrorCode}; - use crate::runtime::approval::CliApprovalPolicy; - use super::{ - cli_approval_metadata, project_routed_permission_event, session_mode_migration_notice, - shared_disconnect_message, shared_restore_error, validated_session_summary, - CliWorkspacePaths, + project_routed_permission_event, session_mode_migration_notice, shared_disconnect_message, + shared_restore_error, validated_session_summary, CliWorkspacePaths, }; use bitfun_agent_runtime_ipc::RuntimeIpcStreamInvalidationReason; @@ -1274,24 +1241,6 @@ mod tests { .is_none()); } - #[test] - fn cli_approval_metadata_keeps_auto_invocation_scoped() { - let auto = cli_approval_metadata(CliApprovalPolicy::Auto); - assert_eq!(auto[AUTO_APPROVE_ASK_CONTEXT_KEY], true); - assert_eq!(auto[USER_INPUT_AVAILABLE_CONTEXT_KEY], false); - - let reject = cli_approval_metadata(CliApprovalPolicy::Reject); - assert_eq!(reject[AUTO_APPROVE_ASK_CONTEXT_KEY], false); - - let ask = cli_approval_metadata(CliApprovalPolicy::Ask); - assert!(!ask.contains_key(AUTO_APPROVE_ASK_CONTEXT_KEY)); - assert!(!ask.contains_key(USER_INPUT_AVAILABLE_CONTEXT_KEY)); - - let disabled = cli_approval_metadata(CliApprovalPolicy::DisableAuto); - assert_eq!(disabled[AUTO_APPROVE_ASK_CONTEXT_KEY], false); - assert!(!disabled.contains_key(USER_INPUT_AVAILABLE_CONTEXT_KEY)); - } - #[test] fn model_updates_use_the_runtime_sdk_without_the_core_compatibility_facade() { let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs new file mode 100644 index 0000000000..ea026582c3 --- /dev/null +++ b/src/apps/cli/src/dispatch/mod.rs @@ -0,0 +1,788 @@ +mod permissions; +pub(crate) mod protocol; +mod runner; +mod store; +mod worker; + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{anyhow, bail, Context, Result}; +use bitfun_core::infrastructure::ai::AIClientFactory; +use bitfun_core::service::config::{AuthConfig, GlobalConfig}; +use serde::de::DeserializeOwned; + +use protocol::{ + DispatchCancelRequest, DispatchCancelResponse, DispatchJobListEntry, DispatchJobState, + DispatchListRequest, DispatchProbeRequest, DispatchProbeResponse, DispatchStatusRequest, + DispatchStatusResponse, DispatchSubmitRequest, DispatchSubmitResponse, DispatchWorkspaceProbe, + DISPATCH_PROTOCOL_VERSION, +}; +use store::{CreateJobOutcome, DispatchStateRecord, DispatchStore}; + +#[derive(Clone, Debug)] +struct ModelReadiness { + available_models: Vec, + default_model: Option, + diagnostic: Option, +} + +impl ModelReadiness { + fn model_configured(&self) -> bool { + self.default_model + .as_ref() + .is_some_and(|default| self.available_models.contains(default)) + } +} + +pub(crate) async fn run_dispatch_verb( + verb: &str, + input: serde_json::Value, +) -> Result { + match verb { + "probe" => { + serde_json::to_value(probe(parse(input)?).await?).context("encode probe response") + } + "submit" => { + serde_json::to_value(submit(parse(input)?).await?).context("encode submit response") + } + "status" => serde_json::to_value(status(parse(input)?)?).context("encode status response"), + "cancel" => serde_json::to_value(cancel(parse(input)?)?).context("encode cancel response"), + "list" => { + let _: DispatchListRequest = parse(input)?; + serde_json::to_value(list()?).context("encode dispatch job list") + } + _ => bail!("unsupported dispatch verb: {verb}"), + } +} + +pub(crate) async fn run_worker(job_id: String) -> Result<()> { + worker::run(job_id).await +} + +async fn probe(request: DispatchProbeRequest) -> Result { + let readiness = inspect_model_readiness().await?; + let workspace = request + .workspace_path + .as_deref() + .map(inspect_workspace) + .transpose()?; + let mut capabilities = vec![ + "persistent_jobs".to_string(), + "cursor_events".to_string(), + "workspace_serialization".to_string(), + "approval_auto".to_string(), + "approval_reject_and_report".to_string(), + "frontend_event_projection".to_string(), + ]; + if runner::is_supported() { + capabilities.push("detached_worker".to_string()); + } + Ok(DispatchProbeResponse { + protocol_version: DISPATCH_PROTOCOL_VERSION, + cli_version: env!("CARGO_PKG_VERSION").to_string(), + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + capabilities, + model_configured: readiness.model_configured(), + available_models: readiness.available_models, + default_model: readiness.default_model, + model_diagnostic: readiness.diagnostic, + workspace, + }) +} + +async fn submit(mut request: DispatchSubmitRequest) -> Result { + validate_submit_request(&request)?; + if !runner::is_supported() { + bail!("dispatch detached workers are supported only on Linux and macOS"); + } + bitfun_agent_runtime::session_control::validate_session_id(&request.session_id) + .map_err(anyhow::Error::msg)?; + let intent = request.clone(); + let store = DispatchStore::open_default()?; + if let Some((record, state)) = store.load_existing_job_for_intent(&intent)? { + ensure_worker_spawned(&store, &record.request.job_id, state.state)?; + return Ok(DispatchSubmitResponse { + accepted: true, + job_id: record.request.job_id, + session_id: record.request.session_id, + state: state.state, + }); + } + + let canonical_workspace = canonical_workspace(&request.workspace_path)?; + request.workspace_path = canonical_workspace.to_string_lossy().to_string(); + let selected_model = select_ready_model(request.model.as_deref()).await?; + request.model = Some(selected_model); + + let title = request + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(|title| truncate_chars(title, 120)) + .unwrap_or_else(|| truncate_chars(request.prompt.trim(), 120)); + request.title = Some(title.clone()); + + let outcome = store.create_job_with_intent(intent, request.clone(), title)?; + let state = match outcome { + CreateJobOutcome::Created(state) | CreateJobOutcome::Existing(state) => { + ensure_worker_spawned(&store, &request.job_id, state.state)?; + state + } + }; + Ok(DispatchSubmitResponse { + accepted: true, + job_id: request.job_id, + session_id: request.session_id, + state: state.state, + }) +} + +fn ensure_worker_spawned( + store: &DispatchStore, + job_id: &str, + state: DispatchJobState, +) -> Result<()> { + if state != DispatchJobState::Queued { + return Ok(()); + } + let Some(_spawn_claim) = store.try_claim_worker_spawn(job_id)? else { + return Ok(()); + }; + // The claim is an OS file lock held through spawn. If this controller + // crashes, an idempotent submit retry can claim and recover the job. + if let Err(error) = runner::spawn(store, job_id) { + store.mark_state( + job_id, + DispatchJobState::Failed, + None, + Some(format!("{error:#}")), + )?; + return Err(error); + } + Ok(()) +} + +fn status(request: DispatchStatusRequest) -> Result { + let store = DispatchStore::open_default()?; + let state = reconcile_worker_liveness(&store, &request.job_id)?; + let page = store.read_events(&request.job_id, request.cursor)?; + Ok(DispatchStatusResponse { + state: state.state, + cursor: page.cursor, + events: page.events, + pending_permissions: Vec::new(), + cursor_reset: page.cursor_reset, + last_error: state.last_error, + }) +} + +fn cancel(request: DispatchCancelRequest) -> Result { + let store = DispatchStore::open_default()?; + cancel_in_store(&store, request, runner::terminate_worker) +} + +fn cancel_in_store( + store: &DispatchStore, + request: DispatchCancelRequest, + terminate: impl FnOnce(u32, &str) -> Result, +) -> Result { + let before = store.request_cancel(&request.job_id)?; + if before.state.is_terminal() { + return Ok(DispatchCancelResponse { + cancelled: before.state == DispatchJobState::Cancelled, + }); + } + + if let Some(pid) = store.read_pid(&request.job_id)? { + if let Err(error) = terminate(pid, &request.job_id) { + let detail = format!("{error:#}"); + let _ = store.record_nonterminal_error(&request.job_id, &detail); + return Err(error); + } + } else if store + .preparing_age_seconds(&request.job_id)? + .is_some_and(|age| age <= runner::PREPARING_GRACE_SECONDS) + { + // The durable request is enough for the new worker to stop itself. + // The caller must poll/retry before treating cancellation as complete. + return Ok(DispatchCancelResponse { cancelled: false }); + } + + // The worker process group is now confirmed absent. Persisting Cancelled + // after that fact prevents a failed signal from hiding a still-running job. + let (cancelled_state, _) = store.mark_state( + &request.job_id, + DispatchJobState::Cancelled, + before.turn_id.as_deref(), + Some("Dispatch cancelled by request".to_string()), + )?; + store.clear_preparing(&request.job_id); + store.remove_pid(&request.job_id); + Ok(DispatchCancelResponse { + cancelled: cancelled_state.state == DispatchJobState::Cancelled, + }) +} + +fn list() -> Result> { + let store = DispatchStore::open_default()?; + let initial = store.list_jobs()?; + for job in &initial { + let _ = reconcile_worker_liveness(&store, &job.job_id); + } + store.list_jobs() +} + +fn reconcile_worker_liveness(store: &DispatchStore, job_id: &str) -> Result { + reconcile_worker_liveness_with_spawn(store, job_id, |store, job_id| { + ensure_worker_spawned(store, job_id, DispatchJobState::Queued) + }) +} + +fn reconcile_worker_liveness_with_spawn( + store: &DispatchStore, + job_id: &str, + spawn_queued_worker: impl FnOnce(&DispatchStore, &str) -> Result<()>, +) -> Result { + let state = store.load_state(job_id)?; + if state.state.is_terminal() { + return Ok(state); + } + let worker_pid = store.read_pid(job_id)?; + if let Some(pid) = worker_pid { + if runner::worker_process_alive(pid, job_id) { + return Ok(state); + } + // A live PID with the wrong command may be a recycled process. Never + // infer cancellation/failure from it or signal it. + if runner::process_alive(pid) { + let message = + format!("dispatch worker pid {pid} is live but does not match job '{job_id}'"); + store.record_nonterminal_error(job_id, &message)?; + return store.load_state(job_id); + } + if runner::worker_process_group_alive(pid) { + // The PID marker authenticates only the leader. Once that process + // is gone, the same numeric PGID may belong to unrelated work and + // must never be signalled by an observer. + let message = format!( + "Dispatch worker leader pid {pid} exited while process group {pid} is still live; \ + refusing to signal the unverified process group because its PGID may have been reused" + ); + let (reconciled, _) = store.mark_state( + job_id, + DispatchJobState::Failed, + state.turn_id.as_deref(), + Some(message), + )?; + store.remove_pid(job_id); + store.clear_preparing(job_id); + return Ok(reconciled); + } + } + if store + .preparing_age_seconds(job_id)? + .is_some_and(|age| age <= runner::PREPARING_GRACE_SECONDS) + { + return Ok(state); + } + if worker_pid.is_none() + && state.state == DispatchJobState::Queued + && state.turn_id.is_none() + && !state.cancel_requested() + { + // job.json is committed before the submit controller starts the + // detached worker. If that controller dies in between, status/list is + // the durable observer that recovers the unstarted job. Once a worker + // PID has been recorded, an unexplained exit still settles Failed + // below instead of creating a crash-restart loop. + spawn_queued_worker(store, job_id)?; + return store.load_state(job_id); + } + // Re-read the cancellation marker while holding the transition lock. A + // concurrent cancel request must win over a stale Failed decision here. + let reconciled = store.settle_exited_worker(job_id)?; + store.remove_pid(job_id); + store.clear_preparing(job_id); + Ok(reconciled) +} + +async fn inspect_model_readiness() -> Result { + bitfun_core::service::config::initialize_global_config() + .await + .map_err(|error| anyhow!("Failed to initialize target model configuration: {error}"))?; + let config_service = bitfun_core::service::config::get_global_config_service() + .await + .map_err(|error| anyhow!("Failed to read target model configuration: {error}"))?; + let config: GlobalConfig = config_service + .get_config(None) + .await + .map_err(|error| anyhow!("Failed to load target model configuration: {error}"))?; + + AIClientFactory::initialize_global() + .await + .map_err(|error| anyhow!("Failed to initialize target model clients: {error}"))?; + let factory = AIClientFactory::get_global() + .await + .map_err(|error| anyhow!("Failed to inspect target model clients: {error}"))?; + + let mut available_models = Vec::new(); + let mut unavailable = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for model in config.ai.models.iter().filter(|model| model.enabled) { + if model.id.trim().is_empty() || !seen.insert(model.id.clone()) { + unavailable.push("an enabled model has an empty or duplicate id".to_string()); + continue; + } + if matches!(model.auth, AuthConfig::ApiKey) && model.api_key.trim().is_empty() { + unavailable.push(format!("model '{}' has no configured credential", model.id)); + continue; + } + match factory.get_client_by_id(&model.id).await { + Ok(_) => available_models.push(model.id.clone()), + Err(error) => unavailable.push(format!("model '{}': {error}", model.id)), + } + } + available_models.sort(); + let selected_default = crate::model_selection::resolve_mode_model_id(&config.ai); + let default_model = selected_default + .filter(|model| available_models.iter().any(|available| available == model)); + + let diagnostic = if available_models.is_empty() { + Some(if unavailable.is_empty() { + "No enabled AI model is configured on the target".to_string() + } else { + format!( + "No ready AI model on the target: {}", + unavailable.join("; ") + ) + }) + } else if default_model.is_none() { + Some("Ready models exist, but the target mode default does not resolve to one".to_string()) + } else if unavailable.is_empty() { + None + } else { + Some(format!( + "Some target models are unavailable: {}", + unavailable.join("; ") + )) + }; + Ok(ModelReadiness { + available_models, + default_model, + diagnostic, + }) +} + +async fn select_ready_model(requested: Option<&str>) -> Result { + let readiness = inspect_model_readiness().await?; + if let Some(requested) = requested.map(str::trim).filter(|model| !model.is_empty()) { + if readiness + .available_models + .iter() + .any(|available| available == requested) + { + return Ok(requested.to_string()); + } + bail!( + "Requested model '{}' is not ready on the dispatch target{}", + requested, + readiness + .diagnostic + .as_deref() + .map(|diagnostic| format!(": {diagnostic}")) + .unwrap_or_default() + ); + } + readiness.default_model.ok_or_else(|| { + anyhow!( + "{}", + readiness + .diagnostic + .unwrap_or_else(|| "No ready default model is configured on the target".to_string()) + ) + }) +} + +pub(super) async fn ensure_selected_model_ready(selected: Option<&str>) -> Result<()> { + let selected = selected + .map(str::trim) + .filter(|model| !model.is_empty()) + .ok_or_else(|| anyhow!("dispatch job has no selected target model"))?; + let resolved = select_ready_model(Some(selected)).await?; + if resolved != selected { + bail!("dispatch target model changed before worker startup"); + } + Ok(()) +} + +fn inspect_workspace(workspace_path: &str) -> Result { + let path = PathBuf::from(workspace_path); + let exists = path.exists(); + let is_directory = path.is_dir(); + let canonical = if is_directory { + path.canonicalize().unwrap_or(path) + } else { + path + }; + let is_git_repository = is_directory + && git_output(&canonical, &["rev-parse", "--is-inside-work-tree"]) + .is_some_and(|output| output.trim() == "true"); + let branch = is_git_repository + .then(|| git_output(&canonical, &["branch", "--show-current"])) + .flatten() + .map(|branch| branch.trim().to_string()) + .filter(|branch| !branch.is_empty()); + let dirty = is_git_repository.then(|| { + git_output(&canonical, &["status", "--porcelain"]) + .is_some_and(|output| !output.trim().is_empty()) + }); + let (ahead, behind) = if is_git_repository { + git_output( + &canonical, + &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], + ) + .and_then(|counts| parse_ahead_behind(&counts)) + .map(|(ahead, behind)| (Some(ahead), Some(behind))) + .unwrap_or((None, None)) + } else { + (None, None) + }; + Ok(DispatchWorkspaceProbe { + path: canonical.to_string_lossy().to_string(), + exists, + is_directory, + is_git_repository, + branch, + dirty, + ahead, + behind, + }) +} + +fn parse_ahead_behind(counts: &str) -> Option<(u64, u64)> { + let mut counts = counts.split_whitespace(); + let ahead = counts.next()?.parse().ok()?; + let behind = counts.next()?.parse().ok()?; + (counts.next().is_none()).then_some((ahead, behind)) +} + +fn git_output(workspace: &Path, args: &[&str]) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(workspace) + .args(args) + .output() + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn canonical_workspace(workspace_path: &str) -> Result { + let path = PathBuf::from(workspace_path.trim()); + if !path.is_absolute() { + bail!("dispatch workspacePath must be absolute"); + } + let canonical = path + .canonicalize() + .with_context(|| format!("resolve dispatch workspace {}", path.display()))?; + if !canonical.is_dir() { + bail!( + "dispatch workspace does not exist or is not a directory: {}", + canonical.display() + ); + } + Ok(canonical) +} + +fn validate_submit_request(request: &DispatchSubmitRequest) -> Result<()> { + if request.protocol_version != DISPATCH_PROTOCOL_VERSION { + bail!( + "unsupported dispatch protocolVersion {}; target requires {}", + request.protocol_version, + DISPATCH_PROTOCOL_VERSION + ); + } + if request.job_id.trim().is_empty() { + bail!("dispatch jobId cannot be empty"); + } + if request.session_id.trim().is_empty() { + bail!("dispatch sessionId cannot be empty"); + } + if request.agent_type.trim().is_empty() { + bail!("dispatch agentType cannot be empty"); + } + if request.prompt.trim().is_empty() { + bail!("dispatch prompt cannot be empty"); + } + if request.prompt.len() > 4 * 1024 * 1024 { + bail!("dispatch prompt exceeds the 4 MiB request limit"); + } + Ok(()) +} + +fn parse(input: serde_json::Value) -> Result { + serde_json::from_value(input).context("invalid dispatch request") +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + let mut chars = value.chars(); + let truncated = chars.by_ref().take(max_chars).collect::(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dispatch::protocol::{DispatchApprovalPolicy, DispatchSubmitRequest}; + + fn test_request(job_id: &str) -> DispatchSubmitRequest { + DispatchSubmitRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: job_id.to_string(), + session_id: format!("session-{job_id}"), + workspace_path: "/tmp/workspace".to_string(), + agent_type: "agentic".to_string(), + prompt: "task".to_string(), + approval_policy: DispatchApprovalPolicy::RejectAndReport, + model: Some("model-1".to_string()), + title: Some("Task".to_string()), + } + } + + #[test] + fn submit_protocol_requires_version_and_explicit_unattended_policy() { + let missing = serde_json::json!({ + "protocolVersion": DISPATCH_PROTOCOL_VERSION, + "jobId": "job-1", + "sessionId": "session-1", + "workspacePath": "/tmp/workspace", + "agentType": "agentic", + "prompt": "task" + }); + assert!(parse::(missing).is_err()); + + let request: DispatchSubmitRequest = parse(serde_json::json!({ + "protocolVersion": DISPATCH_PROTOCOL_VERSION, + "jobId": "job-1", + "sessionId": "session-1", + "workspacePath": "/tmp/workspace", + "agentType": "agentic", + "prompt": "task", + "approvalPolicy": "reject-and-report" + })) + .expect("explicit policy"); + assert_eq!( + request.approval_policy, + DispatchApprovalPolicy::RejectAndReport + ); + + let missing_version = serde_json::json!({ + "jobId": "job-1", + "sessionId": "session-1", + "workspacePath": "/tmp/workspace", + "agentType": "agentic", + "prompt": "task", + "approvalPolicy": "reject-and-report" + }); + assert!(parse::(missing_version).is_err()); + + let mut wrong_version = request; + wrong_version.protocol_version = DISPATCH_PROTOCOL_VERSION + 1; + assert!(validate_submit_request(&wrong_version).is_err()); + } + + #[test] + fn title_preview_is_unicode_safe_and_bounded() { + let title = truncate_chars(&"任务".repeat(80), 120); + assert_eq!(title.chars().count(), 121); + assert!(title.ends_with('…')); + } + + #[test] + fn git_upstream_counts_preserve_ahead_then_behind_order() { + assert_eq!(parse_ahead_behind("3\t5\n"), Some((3, 5))); + assert_eq!(parse_ahead_behind("3"), None); + assert_eq!(parse_ahead_behind("unknown 5"), None); + } + + #[test] + fn cancel_identity_mismatch_stays_retryable_and_non_terminal() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(dir.path().join("dispatch")).expect("store"); + store + .create_job(test_request("job-no-match"), "Task".to_string()) + .expect("create job"); + store + .write_pid("job-no-match", std::process::id()) + .expect("record test pid"); + + let error = cancel_in_store( + &store, + DispatchCancelRequest { + job_id: "job-no-match".to_string(), + }, + runner::terminate_worker, + ) + .expect_err("an unrelated live process must not be treated as cancelled"); + assert!(error.to_string().contains("does not match")); + let state = store.load_state("job-no-match").expect("state"); + assert_eq!(state.state, DispatchJobState::Queued); + assert!(state.cancel_requested()); + } + + #[test] + fn cancel_signal_failure_stays_retryable_and_non_terminal() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(dir.path().join("dispatch")).expect("store"); + store + .create_job(test_request("job-signal-failure"), "Task".to_string()) + .expect("create job"); + store + .write_pid("job-signal-failure", 42) + .expect("record fake pid"); + + let error = cancel_in_store( + &store, + DispatchCancelRequest { + job_id: "job-signal-failure".to_string(), + }, + |_pid, _job_id| bail!("injected signal failure"), + ) + .expect_err("signal failure must remain visible"); + assert!(error.to_string().contains("injected signal failure")); + let state = store.load_state("job-signal-failure").expect("state"); + assert_eq!(state.state, DispatchJobState::Queued); + assert!(state.cancel_requested()); + assert_eq!(state.last_error.as_deref(), Some("injected signal failure")); + } + + #[test] + fn cancel_marks_terminal_only_after_worker_absence_is_confirmed() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(dir.path().join("dispatch")).expect("store"); + store + .create_job(test_request("job-stopped"), "Task".to_string()) + .expect("create job"); + store.write_pid("job-stopped", 42).expect("record fake pid"); + + let response = cancel_in_store( + &store, + DispatchCancelRequest { + job_id: "job-stopped".to_string(), + }, + |_pid, _job_id| Ok(false), + ) + .expect("confirmed stopped worker"); + assert!(response.cancelled); + assert_eq!( + store.load_state("job-stopped").expect("state").state, + DispatchJobState::Cancelled + ); + } + + #[test] + fn status_recovers_job_committed_before_controller_spawn() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(dir.path().join("dispatch")).expect("store"); + store + .create_job(test_request("job-controller-loss"), "Task".to_string()) + .expect("commit job before simulated controller loss"); + let spawn_called = std::cell::Cell::new(false); + + let state = reconcile_worker_liveness_with_spawn( + &store, + "job-controller-loss", + |_store, job_id| { + assert_eq!(job_id, "job-controller-loss"); + spawn_called.set(true); + Ok(()) + }, + ) + .expect("status reconciliation"); + + assert!( + spawn_called.get(), + "the first observer must recover a committed but unstarted job" + ); + assert_eq!(state.state, DispatchJobState::Queued); + assert!( + state.last_error.is_none(), + "controller loss before spawn must not seal the job as Failed" + ); + } + + #[cfg(unix)] + #[test] + fn liveness_reconciliation_does_not_signal_an_unverified_or_reused_process_group() { + use std::os::unix::process::CommandExt; + + struct ProcessGroupGuard(i32); + impl Drop for ProcessGroupGuard { + fn drop(&mut self) { + // SAFETY: this test created the isolated process group. + unsafe { + libc::kill(-self.0, libc::SIGKILL); + } + } + } + + let mut command = std::process::Command::new("/bin/sh"); + command + .args(["-c", "trap '' HUP; sleep 30 &"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } + let mut leader = command.spawn().expect("spawn process-group leader"); + let process_group = leader.id(); + let _guard = ProcessGroupGuard(i32::try_from(process_group).expect("safe pid")); + leader.wait().expect("reap process-group leader"); + assert!(runner::worker_process_group_alive(process_group)); + + let dir = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(dir.path().join("dispatch")).expect("store"); + store + .create_job(test_request("job-orphan-group"), "Task".to_string()) + .expect("create job"); + store + .write_pid("job-orphan-group", process_group) + .expect("record group id"); + + let state = + reconcile_worker_liveness(&store, "job-orphan-group").expect("reconcile orphan group"); + assert_eq!(state.state, DispatchJobState::Failed); + assert!( + state + .last_error + .as_deref() + .is_some_and(|error| error.contains("refusing to signal") + && error.contains("PGID may have been reused")), + "the terminal state must explain why the unverified group was left untouched" + ); + assert!( + runner::worker_process_group_alive(process_group), + "status must not signal an unverified process group" + ); + assert!(store + .read_pid("job-orphan-group") + .expect("pid after reconciliation") + .is_none()); + } +} diff --git a/src/apps/cli/src/dispatch/permissions.rs b/src/apps/cli/src/dispatch/permissions.rs new file mode 100644 index 0000000000..51ef063c02 --- /dev/null +++ b/src/apps/cli/src/dispatch/permissions.rs @@ -0,0 +1,54 @@ +use serde_json::{Map, Value}; + +use crate::runtime::approval::{approval_metadata, CliApprovalPolicy}; + +use super::protocol::DispatchApprovalPolicy; + +pub(crate) const REJECT_AND_REPORT_REASON: &str = + "Dispatch permission policy rejected an action that requires confirmation"; + +pub(crate) const fn cli_policy(policy: DispatchApprovalPolicy) -> CliApprovalPolicy { + match policy { + DispatchApprovalPolicy::Auto => CliApprovalPolicy::Auto, + DispatchApprovalPolicy::RejectAndReport => CliApprovalPolicy::Reject, + } +} + +pub(crate) fn metadata(policy: DispatchApprovalPolicy) -> Map { + approval_metadata(cli_policy(policy)) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_agent_runtime::sdk::AUTO_APPROVE_ASK_CONTEXT_KEY; + use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; + + #[test] + fn dispatch_policy_uses_the_shared_invocation_metadata_contract() { + let auto = metadata(DispatchApprovalPolicy::Auto); + assert_eq!( + auto.get(USER_INPUT_AVAILABLE_CONTEXT_KEY), + Some(&Value::Bool(false)) + ); + assert_eq!( + auto.get(AUTO_APPROVE_ASK_CONTEXT_KEY), + Some(&Value::Bool(true)) + ); + + let reject = metadata(DispatchApprovalPolicy::RejectAndReport); + assert_eq!( + reject.get(USER_INPUT_AVAILABLE_CONTEXT_KEY), + Some(&Value::Bool(false)) + ); + assert_eq!( + reject.get(AUTO_APPROVE_ASK_CONTEXT_KEY), + Some(&Value::Bool(false)) + ); + assert_eq!( + reject, + approval_metadata(CliApprovalPolicy::Reject), + "dispatch must not invent a second approval mechanism" + ); + } +} diff --git a/src/apps/cli/src/dispatch/protocol.rs b/src/apps/cli/src/dispatch/protocol.rs new file mode 100644 index 0000000000..f61a1c4f57 --- /dev/null +++ b/src/apps/cli/src/dispatch/protocol.rs @@ -0,0 +1,263 @@ +use serde::{Deserialize, Serialize}; + +pub(crate) const DISPATCH_PROTOCOL_VERSION: u32 = 1; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchProbeRequest { + #[serde(default)] + pub(crate) workspace_path: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchWorkspaceProbe { + pub(crate) path: String, + pub(crate) exists: bool, + pub(crate) is_directory: bool, + pub(crate) is_git_repository: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) dirty: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) ahead: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) behind: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchProbeResponse { + pub(crate) protocol_version: u32, + pub(crate) cli_version: String, + pub(crate) os: String, + pub(crate) arch: String, + pub(crate) capabilities: Vec, + pub(crate) model_configured: bool, + pub(crate) available_models: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) default_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) model_diagnostic: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) workspace: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum DispatchApprovalPolicy { + Auto, + RejectAndReport, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchSubmitRequest { + pub(crate) protocol_version: u32, + pub(crate) job_id: String, + pub(crate) session_id: String, + pub(crate) workspace_path: String, + pub(crate) agent_type: String, + pub(crate) prompt: String, + pub(crate) approval_policy: DispatchApprovalPolicy, + #[serde(default)] + pub(crate) model: Option, + #[serde(default)] + pub(crate) title: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum DispatchJobState { + Queued, + Running, + Succeeded, + Failed, + Cancelled, +} + +impl DispatchJobState { + pub(crate) const fn is_terminal(self) -> bool { + matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled) + } +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchSubmitResponse { + pub(crate) accepted: bool, + pub(crate) job_id: String, + pub(crate) session_id: String, + pub(crate) state: DispatchJobState, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchStatusRequest { + pub(crate) job_id: String, + #[serde(default)] + pub(crate) cursor: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchCancelRequest { + pub(crate) job_id: String, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchListRequest {} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(tag = "type", rename_all = "camelCase")] +pub(crate) enum DispatchEvent { + Audit { + timestamp: String, + action: String, + details: serde_json::Value, + }, + JobState { + timestamp: String, + state: DispatchJobState, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, + AgentEvent { + timestamp: String, + event: serde_json::Value, + #[serde(rename = "frontendEventName", skip_serializing_if = "Option::is_none")] + frontend_event_name: Option, + #[serde(rename = "frontendPayload", skip_serializing_if = "Option::is_none")] + frontend_payload: Option, + }, + PermissionRejected { + timestamp: String, + request: serde_json::Value, + reason: String, + }, +} + +impl DispatchEvent { + pub(crate) fn approval_policy_selected(policy: DispatchApprovalPolicy) -> Self { + Self::Audit { + timestamp: chrono::Utc::now().to_rfc3339(), + action: "approvalPolicySelected".to_string(), + details: serde_json::json!({ "approvalPolicy": policy }), + } + } + + pub(crate) fn cancel_requested() -> Self { + Self::Audit { + timestamp: chrono::Utc::now().to_rfc3339(), + action: "cancelRequested".to_string(), + details: serde_json::json!({}), + } + } + + pub(crate) fn oversized_event_omitted(encoded_bytes: usize, max_bytes: usize) -> Self { + Self::Audit { + timestamp: chrono::Utc::now().to_rfc3339(), + action: "eventOmitted".to_string(), + details: serde_json::json!({ + "reason": "eventTooLarge", + "encodedBytes": encoded_bytes, + "maxBytes": max_bytes, + }), + } + } + + pub(crate) fn job_state(state: DispatchJobState, message: impl Into>) -> Self { + Self::JobState { + timestamp: chrono::Utc::now().to_rfc3339(), + state, + message: message.into(), + } + } + + pub(crate) fn agent_event( + event: serde_json::Value, + frontend_projection: Option<(String, serde_json::Value)>, + ) -> Self { + let (frontend_event_name, frontend_payload) = frontend_projection + .map(|(name, payload)| (Some(name), Some(payload))) + .unwrap_or((None, None)); + Self::AgentEvent { + timestamp: chrono::Utc::now().to_rfc3339(), + event, + frontend_event_name, + frontend_payload, + } + } + + pub(crate) fn permission_rejected( + request: serde_json::Value, + reason: impl Into, + ) -> Self { + Self::PermissionRejected { + timestamp: chrono::Utc::now().to_rfc3339(), + request, + reason: reason.into(), + } + } +} + +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchStatusResponse { + pub(crate) state: DispatchJobState, + pub(crate) cursor: u64, + pub(crate) events: Vec, + pub(crate) pending_permissions: Vec, + pub(crate) cursor_reset: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) last_error: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchCancelResponse { + pub(crate) cancelled: bool, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchJobListEntry { + pub(crate) job_id: String, + pub(crate) session_id: String, + pub(crate) state: DispatchJobState, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) started_at: Option, + pub(crate) workspace_path: String, + pub(crate) title: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_names_are_camel_case_and_policy_values_are_explicit() { + let event = DispatchEvent::AgentEvent { + timestamp: "2026-01-01T00:00:00Z".to_string(), + event: serde_json::json!({"id": "event-1"}), + frontend_event_name: Some("agentic://text-chunk".to_string()), + frontend_payload: Some(serde_json::json!({"sessionId": "session-1"})), + }; + let value = serde_json::to_value(event).expect("serialize event"); + assert_eq!(value["type"], "agentEvent"); + assert_eq!(value["frontendEventName"], "agentic://text-chunk"); + assert_eq!(value["frontendPayload"]["sessionId"], "session-1"); + + assert_eq!( + serde_json::to_value(DispatchApprovalPolicy::RejectAndReport) + .expect("serialize policy"), + "reject-and-report" + ); + assert_eq!( + serde_json::to_value(DispatchJobState::Running).expect("serialize state"), + "running" + ); + } +} diff --git a/src/apps/cli/src/dispatch/runner.rs b/src/apps/cli/src/dispatch/runner.rs new file mode 100644 index 0000000000..d20da54b45 --- /dev/null +++ b/src/apps/cli/src/dispatch/runner.rs @@ -0,0 +1,377 @@ +use std::process::{Command, Stdio}; +use std::time::Duration; + +use anyhow::{anyhow, bail, Context, Result}; + +use super::store::DispatchStore; + +pub(crate) const PREPARING_GRACE_SECONDS: u64 = 10; + +pub(crate) fn is_supported() -> bool { + cfg!(any(target_os = "linux", target_os = "macos")) +} + +pub(crate) fn spawn(store: &DispatchStore, job_id: &str) -> Result { + if !is_supported() { + bail!("dispatch detached workers are supported only on Linux and macOS"); + } + + let executable = std::env::current_exe().context("resolve BitFun executable")?; + let mut command = bitfun_services_core::process_manager::create_command(executable); + command + .arg("dispatch") + .arg("__run") + .arg("--job") + .arg(job_id) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Some(home) = dirs::home_dir() { + command.current_dir(home); + } + configure_detached_process(&mut command); + + let child = match command.spawn().context("start detached dispatch worker") { + Ok(child) => child, + Err(error) => { + store.clear_preparing(job_id); + return Err(error); + } + }; + let pid = child.id(); + // The winning child records its own PID after acquiring the per-job worker + // lease. This closes the parent-crash window without letting a duplicate + // retry overwrite the active worker's identity. + Ok(pid) +} + +pub(crate) fn worker_process_alive(pid: u32, job_id: &str) -> bool { + process_alive(pid) && process_matches_job(pid, job_id) +} + +pub(crate) fn worker_process_group_alive(pid: u32) -> bool { + #[cfg(unix)] + { + i32::try_from(pid) + .ok() + .is_some_and(|process_group| process_group > 1 && process_group_alive(process_group)) + } + #[cfg(not(unix))] + { + let _ = pid; + false + } +} + +pub(crate) fn terminate_worker(pid: u32, job_id: &str) -> Result { + let signed_pid = i32::try_from(pid).map_err(|_| anyhow!("worker pid is out of range"))?; + if signed_pid <= 1 { + bail!("refusing to signal unsafe dispatch worker pid {pid}"); + } + #[cfg(unix)] + { + let pid = signed_pid; + if !process_alive(pid as u32) { + if process_group_alive(pid) { + bail!( + "dispatch worker leader pid {pid} is no longer alive; refusing to signal \ + unverified process group {pid} because its PGID may have been reused" + ); + } + return Ok(false); + } + if !process_matches_job(pid as u32, job_id) { + bail!("dispatch worker pid {pid} does not match job '{job_id}'"); + } + if pid as u32 == std::process::id() { + bail!("refusing to signal the current dispatch process {pid}"); + } + + // The live, identity-verified worker called setsid, so its PID is also + // the process-group ID. Never enter this signalling path from a marker + // whose leader has already disappeared. + if !signal_process_group(pid, libc::SIGTERM)? { + return Ok(true); + } + if wait_for_process_group_exit(pid) { + return Ok(true); + } + + // Escalation requires a fresh, exact leader identity. The group may + // have emptied and its numeric PGID may have been reused during the + // TERM grace period, so an absent leader can no longer authenticate + // any remaining group even though it was verified before TERM. + if !process_alive(pid as u32) { + bail!( + "dispatch worker leader pid {pid} exited after SIGTERM; refusing to signal \ + unverified process group {pid} because its PGID may have been reused" + ); + } + if !process_matches_job(pid as u32, job_id) { + bail!("dispatch worker pid {pid} changed identity before SIGKILL"); + } + if !signal_process_group(pid, libc::SIGKILL)? { + return Ok(true); + } + if !wait_for_process_group_exit(pid) { + bail!("dispatch worker process group {pid} remained alive after SIGKILL"); + } + Ok(true) + } + #[cfg(not(unix))] + { + let _ = (pid, job_id); + bail!("dispatch worker cancellation is unsupported on this platform") + } +} + +fn configure_detached_process(command: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } + } + #[cfg(not(unix))] + let _ = command; +} + +#[cfg(unix)] +fn signal_process_group(process_group: i32, signal: i32) -> Result { + // SAFETY: callers validate the positive process-group id and worker + // identity before signalling. + if unsafe { libc::kill(-process_group, signal) } == 0 { + return Ok(true); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + Ok(false) + } else { + Err(error).with_context(|| { + format!("signal dispatch worker process group {process_group} with {signal}") + }) + } +} + +#[cfg(unix)] +fn wait_for_process_group_exit(process_group: i32) -> bool { + for _ in 0..40 { + if !process_group_alive(process_group) { + return true; + } + std::thread::sleep(Duration::from_millis(25)); + } + !process_group_alive(process_group) +} + +#[cfg(unix)] +fn process_group_alive(process_group: i32) -> bool { + // SAFETY: signal 0 performs liveness/permission checking only. + if unsafe { libc::kill(-process_group, 0) } == 0 { + return true; + } + matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ) +} + +#[cfg(unix)] +pub(crate) fn process_alive(pid: u32) -> bool { + let Ok(pid) = i32::try_from(pid) else { + return false; + }; + // SAFETY: signal 0 performs liveness/permission checking only. + if unsafe { libc::kill(pid, 0) } == 0 { + return true; + } + matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ) +} + +#[cfg(not(unix))] +pub(crate) fn process_alive(_pid: u32) -> bool { + false +} + +#[cfg(target_os = "linux")] +fn process_matches_job(pid: u32, job_id: &str) -> bool { + let Ok(raw) = std::fs::read(format!("/proc/{pid}/cmdline")) else { + return false; + }; + let args = raw + .split(|byte| *byte == 0) + .filter(|arg| !arg.is_empty()) + .map(|arg| String::from_utf8_lossy(arg).into_owned()) + .collect::>(); + arguments_match_job(&args, job_id) +} + +#[cfg(target_os = "macos")] +fn process_matches_job(pid: u32, job_id: &str) -> bool { + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "command="]) + .output(); + let Ok(output) = output else { + return false; + }; + if !output.status.success() { + return false; + } + let command = String::from_utf8_lossy(&output.stdout); + let args = command + .split_whitespace() + .map(ToOwned::to_owned) + .collect::>(); + arguments_match_job(&args, job_id) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn process_matches_job(_pid: u32, _job_id: &str) -> bool { + false +} + +fn arguments_match_job(args: &[String], job_id: &str) -> bool { + args.windows(4).any(|window| { + window[0] == "dispatch" + && window[1] == "__run" + && window[2] == "--job" + && window[3] == job_id + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detached_runner_support_matches_the_v1_platform_contract() { + assert_eq!( + is_supported(), + cfg!(any(target_os = "linux", target_os = "macos")) + ); + } + + #[test] + fn current_test_process_is_not_mistaken_for_a_dispatch_worker() { + assert!(!worker_process_alive(std::process::id(), "job-1")); + } + + #[test] + fn process_identity_requires_the_exact_hidden_worker_arguments() { + let expected = ["bitfun", "dispatch", "__run", "--job", "job-1"].map(str::to_string); + assert!(arguments_match_job(&expected, "job-1")); + assert!(!arguments_match_job(&expected, "job-2")); + let unrelated = ["bitfun", "dispatch", "status"].map(str::to_string); + assert!(!arguments_match_job(&unrelated, "job-1")); + } + + #[cfg(unix)] + #[test] + fn cancellation_does_not_signal_an_unverified_group_after_leader_exit() { + struct ProcessGroupGuard(i32); + impl Drop for ProcessGroupGuard { + fn drop(&mut self) { + // SAFETY: this test created the isolated process group. + unsafe { + libc::kill(-self.0, libc::SIGKILL); + } + } + } + + let mut command = Command::new("/bin/sh"); + command + .args(["-c", "trap '' HUP; sleep 30 &"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_detached_process(&mut command); + let mut leader = command.spawn().expect("spawn process-group leader"); + let process_group = i32::try_from(leader.id()).expect("safe pid"); + let _guard = ProcessGroupGuard(process_group); + leader.wait().expect("reap process-group leader"); + assert!(!process_alive(process_group as u32)); + assert!(process_group_alive(process_group)); + + let error = terminate_worker(process_group as u32, "leader-already-exited") + .expect_err("an absent leader cannot authenticate the remaining process group"); + assert!(error.to_string().contains("refusing to signal")); + assert!(error.to_string().contains("PGID may have been reused")); + assert!( + process_group_alive(process_group), + "the fail-safe path must not signal an unverified process group" + ); + } + + #[cfg(unix)] + #[test] + fn cancellation_does_not_escalate_after_term_exits_the_verified_leader() { + struct ProcessGroupGuard(i32); + impl Drop for ProcessGroupGuard { + fn drop(&mut self) { + // SAFETY: this test created the isolated process group. + unsafe { + libc::kill(-self.0, libc::SIGKILL); + } + } + } + + let job_id = "leader-exits-after-term"; + let ready_dir = tempfile::tempdir().expect("ready directory"); + let ready_path = ready_dir.path().join("term-resistant-child-ready"); + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg( + "trap 'exit 0' TERM; trap '' HUP; \ + sh -c 'trap \"\" TERM HUP; printf ready > \"$BITFUN_DISPATCH_TERM_TEST_READY\"; \ + while :; do sleep 30; done' & \ + while :; do sleep 30; done", + ) + // These trailing arguments make the real process identity match + // the hidden worker contract without launching BitFun Runtime. + .args(["dispatch", "__run", "--job", job_id]) + .env("BITFUN_DISPATCH_TERM_TEST_READY", &ready_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_detached_process(&mut command); + let mut leader = command.spawn().expect("spawn process-group leader"); + let process_group = i32::try_from(leader.id()).expect("safe pid"); + let _guard = ProcessGroupGuard(process_group); + assert!(worker_process_alive(process_group as u32, job_id)); + for _ in 0..100 { + if ready_path.is_file() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + ready_path.is_file(), + "TERM-resistant child must be ready before cancellation" + ); + let reaper = std::thread::spawn(move || leader.wait()); + + let error = terminate_worker(process_group as u32, job_id) + .expect_err("SIGKILL must not follow a vanished leader"); + assert!(error.to_string().contains("exited after SIGTERM")); + assert!(error.to_string().contains("refusing to signal")); + assert!( + process_group_alive(process_group), + "the TERM-resistant child proves SIGKILL was not sent" + ); + reaper + .join() + .expect("join leader reaper") + .expect("reap process-group leader"); + } +} diff --git a/src/apps/cli/src/dispatch/store.rs b/src/apps/cli/src/dispatch/store.rs new file mode 100644 index 0000000000..79e79508de --- /dev/null +++ b/src/apps/cli/src/dispatch/store.rs @@ -0,0 +1,1621 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Context, Result}; +use serde::{Deserialize, Serialize}; + +use super::protocol::{ + DispatchEvent, DispatchJobListEntry, DispatchJobState, DispatchSubmitRequest, + DISPATCH_PROTOCOL_VERSION, +}; + +const JOB_RECORD_FILE: &str = "job.json"; +const STATE_FILE: &str = "state"; +const EVENTS_FILE: &str = "events.ndjson"; +const EVENTS_LOCK_FILE: &str = ".events.lock"; +const PID_FILE: &str = "job.pid"; +const PREPARING_FILE: &str = "preparing"; +const SPAWN_LOCK_FILE: &str = ".spawn.lock"; +const WORKER_LOCK_FILE: &str = ".worker.lock"; +const DEFAULT_MAX_EVENTS_BYTES: u64 = 64 * 1024 * 1024; +// Keep a single projected event and a complete status page comfortably below +// the server transport's 256 KiB WebSocket frame ceiling. +const MAX_EVENT_BYTES: usize = 96 * 1024; +const MAX_STATUS_PAGE_BYTES: u64 = 128 * 1024; +const MAX_STATUS_PAGE_EVENTS: usize = 512; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct EventLogHeader { + cursor_base: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchJobRecord { + pub(crate) protocol_version: u32, + pub(crate) intent_sha256: String, + pub(crate) request: DispatchSubmitRequest, + pub(crate) created_at: String, + pub(crate) title: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchStateRecord { + pub(crate) state: DispatchJobState, + #[serde(default)] + pub(crate) started_at: Option, + #[serde(default)] + pub(crate) finished_at: Option, + #[serde(default)] + pub(crate) turn_id: Option, + #[serde(default)] + pub(crate) cancel_requested_at: Option, + #[serde(default)] + pub(crate) last_error: Option, +} + +impl DispatchStateRecord { + fn queued() -> Self { + Self { + state: DispatchJobState::Queued, + started_at: None, + finished_at: None, + turn_id: None, + cancel_requested_at: None, + last_error: None, + } + } + + pub(crate) fn cancel_requested(&self) -> bool { + self.cancel_requested_at.is_some() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CreateJobOutcome { + Created(DispatchStateRecord), + Existing(DispatchStateRecord), +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct EventPage { + pub(crate) cursor: u64, + pub(crate) events: Vec, + pub(crate) cursor_reset: bool, +} + +#[derive(Clone, Debug)] +pub(crate) struct DispatchStore { + root: PathBuf, + max_events_bytes: u64, +} + +impl DispatchStore { + pub(crate) fn open_default() -> Result { + let path_manager = bitfun_core::infrastructure::PathManager::new() + .map_err(|error| anyhow!("resolve BitFun storage root: {error}"))?; + Self::open(path_manager.bitfun_home_dir().join("dispatch")) + } + + pub(crate) fn open(root: PathBuf) -> Result { + create_private_dir(&root)?; + create_private_dir(&root.join("jobs"))?; + create_private_dir(&root.join("workspaces"))?; + Ok(Self { + root, + max_events_bytes: DEFAULT_MAX_EVENTS_BYTES, + }) + } + + pub(crate) fn create_job_with_intent( + &self, + intent: DispatchSubmitRequest, + request: DispatchSubmitRequest, + title: String, + ) -> Result { + validate_id("jobId", &request.job_id)?; + let intent_sha256 = submit_intent_fingerprint(&intent)?; + let job_dir = self.job_dir(&request.job_id)?; + create_private_dir(&job_dir)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + + let record_path = job_dir.join(JOB_RECORD_FILE); + match fs::symlink_metadata(&record_path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!( + "dispatch job commit marker is not a regular file: {}", + request.job_id + ); + } + let existing = read_json::(&record_path)?; + if existing.intent_sha256 != intent_sha256 { + bail!( + "jobId '{}' already exists with a different dispatch request", + request.job_id + ); + } + return Ok(CreateJobOutcome::Existing( + self.load_state_unlocked(&job_dir)?, + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "inspect dispatch job commit marker {}", + record_path.display() + ) + }) + } + } + + let record = DispatchJobRecord { + protocol_version: DISPATCH_PROTOCOL_VERSION, + intent_sha256, + request, + created_at: chrono::Utc::now().to_rfc3339(), + title, + }; + // job.json is the commit marker. Any fragments left without it came + // from an interrupted initialization and are safely rebuilt while the + // job lock is held. Publishing the record last means its presence + // guarantees state and the initial event stream are already durable. + let state = DispatchStateRecord::queued(); + atomic_write_json(&job_dir.join(STATE_FILE), &state)?; + ensure_private_file(&job_dir.join(EVENTS_LOCK_FILE))?; + atomic_write_event_log(&job_dir.join(EVENTS_FILE), 0, None)?; + self.append_event_unlocked( + &job_dir, + &DispatchEvent::approval_policy_selected(record.request.approval_policy), + )?; + self.append_event_unlocked( + &job_dir, + &DispatchEvent::job_state(DispatchJobState::Queued, None), + )?; + atomic_write_json(&record_path, &record)?; + Ok(CreateJobOutcome::Created(state)) + } + + #[cfg(test)] + pub(crate) fn create_job( + &self, + request: DispatchSubmitRequest, + title: String, + ) -> Result { + self.create_job_with_intent(request.clone(), request, title) + } + + pub(crate) fn load_existing_job_for_intent( + &self, + intent: &DispatchSubmitRequest, + ) -> Result> { + validate_id("jobId", &intent.job_id)?; + let job_dir = self.job_dir(&intent.job_id)?; + let metadata = match fs::symlink_metadata(&job_dir) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error) + .with_context(|| format!("inspect dispatch job {}", job_dir.display())) + } + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!( + "dispatch job path is not a private directory: {}", + intent.job_id + ); + } + let _lock = JobLock::shared(&job_dir.join(".lock"))?; + let record_path = job_dir.join(JOB_RECORD_FILE); + let record_metadata = match fs::symlink_metadata(&record_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error).with_context(|| { + format!( + "inspect dispatch job commit marker {}", + record_path.display() + ) + }) + } + }; + if record_metadata.file_type().is_symlink() || !record_metadata.is_file() { + bail!( + "dispatch job commit marker is not a regular file: {}", + intent.job_id + ); + } + let record = read_json::(&record_path)?; + if record.intent_sha256 != submit_intent_fingerprint(intent)? { + bail!( + "jobId '{}' already exists with a different dispatch request", + intent.job_id + ); + } + let state = self.load_state_unlocked(&job_dir)?; + Ok(Some((record, state))) + } + + pub(crate) fn load_job(&self, job_id: &str) -> Result { + let job_dir = self.existing_job_dir(job_id)?; + read_json(&job_dir.join(JOB_RECORD_FILE)) + } + + pub(crate) fn load_state(&self, job_id: &str) -> Result { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::shared(&job_dir.join(".lock"))?; + self.load_state_unlocked(&job_dir) + } + + pub(crate) fn mark_state( + &self, + job_id: &str, + state: DispatchJobState, + turn_id: Option<&str>, + message: Option, + ) -> Result<(DispatchStateRecord, bool)> { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let mut current = self.load_state_unlocked(&job_dir)?; + if current.state.is_terminal() { + return Ok((current, false)); + } + if current.state == state { + if current.turn_id.is_none() { + current.turn_id = turn_id.map(ToOwned::to_owned); + atomic_write_json(&job_dir.join(STATE_FILE), ¤t)?; + } + return Ok((current, false)); + } + + let now = chrono::Utc::now().to_rfc3339(); + current.state = state; + if state == DispatchJobState::Running && current.started_at.is_none() { + current.started_at = Some(now.clone()); + } + if state.is_terminal() { + current.finished_at = Some(now); + } + if let Some(turn_id) = turn_id { + current.turn_id = Some(turn_id.to_string()); + } + if state.is_terminal() { + current.last_error = if state == DispatchJobState::Failed { + message.clone() + } else { + None + }; + } + atomic_write_json(&job_dir.join(STATE_FILE), ¤t)?; + self.append_event_unlocked(&job_dir, &DispatchEvent::job_state(state, message))?; + Ok((current, true)) + } + + pub(crate) fn request_cancel(&self, job_id: &str) -> Result { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let mut state = self.load_state_unlocked(&job_dir)?; + if state.state.is_terminal() || state.cancel_requested() { + return Ok(state); + } + state.cancel_requested_at = Some(chrono::Utc::now().to_rfc3339()); + state.last_error = None; + atomic_write_json(&job_dir.join(STATE_FILE), &state)?; + self.append_event_unlocked(&job_dir, &DispatchEvent::cancel_requested())?; + Ok(state) + } + + pub(crate) fn record_nonterminal_error(&self, job_id: &str, error: &str) -> Result<()> { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let mut state = self.load_state_unlocked(&job_dir)?; + if !state.state.is_terminal() { + state.last_error = Some(error.to_string()); + atomic_write_json(&job_dir.join(STATE_FILE), &state)?; + } + Ok(()) + } + + pub(crate) fn settle_exited_worker(&self, job_id: &str) -> Result { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let mut state = self.load_state_unlocked(&job_dir)?; + if state.state.is_terminal() { + return Ok(state); + } + + let (terminal_state, message) = if state.cancel_requested() { + ( + DispatchJobState::Cancelled, + "Dispatch worker stopped after a cancellation request", + ) + } else if state.turn_id.is_some() { + ( + DispatchJobState::Failed, + "Dispatch worker exited after reserving a turn; the prompt was not replayed to avoid duplicate side effects", + ) + } else { + ( + DispatchJobState::Failed, + "Dispatch worker exited without writing a terminal state", + ) + }; + state.state = terminal_state; + state.finished_at = Some(chrono::Utc::now().to_rfc3339()); + state.last_error = if terminal_state == DispatchJobState::Failed { + Some(message.to_string()) + } else { + None + }; + atomic_write_json(&job_dir.join(STATE_FILE), &state)?; + self.append_event_unlocked( + &job_dir, + &DispatchEvent::job_state(terminal_state, Some(message.to_string())), + )?; + Ok(state) + } + + pub(crate) fn record_turn_id(&self, job_id: &str, turn_id: &str) -> Result<()> { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let mut state = self.load_state_unlocked(&job_dir)?; + if !state.state.is_terminal() && state.turn_id.as_deref() != Some(turn_id) { + state.turn_id = Some(turn_id.to_string()); + atomic_write_json(&job_dir.join(STATE_FILE), &state)?; + } + Ok(()) + } + + pub(crate) fn try_claim_worker_spawn(&self, job_id: &str) -> Result> { + let job_dir = self.existing_job_dir(job_id)?; + let Some(lease) = DispatchLease::try_acquire(&job_dir.join(SPAWN_LOCK_FILE))? else { + return Ok(None); + }; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let state = self.load_state_unlocked(&job_dir)?; + if state.state != DispatchJobState::Queued + || state.turn_id.is_some() + || state.cancel_requested() + { + return Ok(None); + } + if let Some(pid) = self.read_pid(job_id)? { + if super::runner::process_alive(pid) { + return Ok(None); + } + remove_file_if_present(&job_dir.join(PID_FILE)); + } + atomic_write( + &job_dir.join(PREPARING_FILE), + chrono::Utc::now().to_rfc3339().as_bytes(), + )?; + Ok(Some(lease)) + } + + pub(crate) fn try_acquire_worker_lease(&self, job_id: &str) -> Result> { + let job_dir = self.existing_job_dir(job_id)?; + DispatchLease::try_acquire(&job_dir.join(WORKER_LOCK_FILE)) + } + + pub(crate) fn append_event(&self, job_id: &str, event: &DispatchEvent) -> Result { + let job_dir = self.existing_job_dir(job_id)?; + self.append_event_unlocked(&job_dir, event) + } + + pub(crate) fn read_events(&self, job_id: &str, cursor: u64) -> Result { + let job_dir = self.existing_job_dir(job_id)?; + let lock_path = job_dir.join(EVENTS_LOCK_FILE); + let lock_file = OpenOptions::new() + .read(true) + .write(true) + .open(&lock_path) + .with_context(|| format!("open dispatch event lock {}", lock_path.display()))?; + let _lock = FileLock::shared(&lock_file)?; + let path = job_dir.join(EVENTS_FILE); + let mut file = OpenOptions::new() + .read(true) + .open(&path) + .with_context(|| format!("open dispatch events {}", path.display()))?; + set_private_file_permissions(&path)?; + let len = file.metadata()?.len(); + let (header, data_start) = read_event_log_header(&mut file, &path)?; + let data_len = len.saturating_sub(data_start); + let retained_end = header.cursor_base.saturating_add(data_len); + let (start, cursor_reset) = if cursor < header.cursor_base || cursor > retained_end { + (0, true) + } else { + (cursor.saturating_sub(header.cursor_base), false) + }; + file.seek(SeekFrom::Start(data_start.saturating_add(start)))?; + let mut bytes = Vec::new(); + (&mut file) + .take(MAX_STATUS_PAGE_BYTES) + .read_to_end(&mut bytes)?; + + let mut events = Vec::new(); + let mut consumed = 0_usize; + while events.len() < MAX_STATUS_PAGE_EVENTS { + let Some(relative_newline) = bytes[consumed..].iter().position(|byte| *byte == b'\n') + else { + break; + }; + let line_end = consumed + relative_newline; + let line = &bytes[consumed..line_end]; + consumed = line_end + 1; + if line.is_empty() { + continue; + } + let event = serde_json::from_slice(line) + .with_context(|| format!("decode dispatch event for job {job_id}"))?; + events.push(event); + } + Ok(EventPage { + cursor: header + .cursor_base + .saturating_add(start) + .saturating_add(consumed as u64), + events, + cursor_reset, + }) + } + + pub(crate) fn list_jobs(&self) -> Result> { + let jobs_dir = self.root.join("jobs"); + let mut entries = Vec::new(); + for entry in fs::read_dir(&jobs_dir) + .with_context(|| format!("read dispatch jobs {}", jobs_dir.display()))? + { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let Some(job_id) = entry.file_name().to_str().map(ToOwned::to_owned) else { + continue; + }; + let Ok(job) = self.load_job(&job_id) else { + continue; + }; + let Ok(state) = self.load_state(&job_id) else { + continue; + }; + entries.push(DispatchJobListEntry { + job_id, + session_id: job.request.session_id, + state: state.state, + started_at: state.started_at, + workspace_path: job.request.workspace_path, + title: job.title, + }); + } + entries.sort_by(|left, right| right.started_at.cmp(&left.started_at)); + Ok(entries) + } + + pub(crate) fn write_pid(&self, job_id: &str, pid: u32) -> Result<()> { + let job_dir = self.existing_job_dir(job_id)?; + atomic_write(&job_dir.join(PID_FILE), format!("{pid}\n").as_bytes()) + } + + pub(crate) fn read_pid(&self, job_id: &str) -> Result> { + let job_dir = self.existing_job_dir(job_id)?; + let path = job_dir.join(PID_FILE); + match fs::read_to_string(&path) { + Ok(raw) => { + let pid = raw + .trim() + .parse::() + .with_context(|| format!("decode worker pid {}", path.display()))?; + if pid <= 1 || i32::try_from(pid).is_err() { + bail!("dispatch worker pid is outside the safe process range: {pid}"); + } + Ok(Some(pid)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("read worker pid {}", path.display())), + } + } + + pub(crate) fn remove_pid(&self, job_id: &str) { + if let Ok(job_dir) = self.job_dir(job_id) { + remove_file_if_present(&job_dir.join(PID_FILE)); + } + } + + pub(crate) fn remove_pid_if_matches(&self, job_id: &str, expected_pid: u32) { + if matches!(self.read_pid(job_id), Ok(Some(pid)) if pid == expected_pid) { + self.remove_pid(job_id); + } + } + + pub(crate) fn clear_preparing(&self, job_id: &str) { + if let Ok(job_dir) = self.job_dir(job_id) { + remove_file_if_present(&job_dir.join(PREPARING_FILE)); + } + } + + pub(crate) fn preparing_age_seconds(&self, job_id: &str) -> Result> { + let job_dir = self.existing_job_dir(job_id)?; + let path = job_dir.join(PREPARING_FILE); + let metadata = match fs::metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error) + .with_context(|| format!("read preparing marker {}", path.display())) + } + }; + let modified = metadata.modified()?; + Ok(Some(modified.elapsed().unwrap_or_default().as_secs())) + } + + pub(crate) fn workspace_lock_path(&self, workspace_path: &str) -> PathBuf { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(workspace_path.as_bytes()); + self.root + .join("workspaces") + .join(format!("{digest:x}.lock")) + } + + fn load_state_unlocked(&self, job_dir: &Path) -> Result { + read_json(&job_dir.join(STATE_FILE)) + } + + fn append_event_unlocked(&self, job_dir: &Path, event: &DispatchEvent) -> Result { + let lock_path = job_dir.join(EVENTS_LOCK_FILE); + let lock_file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&lock_path) + .with_context(|| format!("open dispatch event lock {}", lock_path.display()))?; + set_private_file_permissions(&lock_path)?; + let _lock = FileLock::exclusive(&lock_file)?; + let path = job_dir.join(EVENTS_FILE); + let mut file = OpenOptions::new() + .append(true) + .read(true) + .open(&path) + .with_context(|| format!("open dispatch events {}", path.display()))?; + set_private_file_permissions(&path)?; + let encoded = serde_json::to_vec(event).context("encode dispatch event")?; + let encoded = if encoded.len() > MAX_EVENT_BYTES { + serde_json::to_vec(&DispatchEvent::oversized_event_omitted( + encoded.len(), + MAX_EVENT_BYTES, + )) + .context("encode oversized dispatch event marker")? + } else { + encoded + }; + let (header, data_start) = read_event_log_header(&mut file, &path)?; + let physical_len = truncate_incomplete_event_tail(&mut file)?; + let current_len = physical_len.saturating_sub(data_start); + if current_len + .saturating_add(encoded.len() as u64) + .saturating_add(1) + > self.max_events_bytes + { + let cursor_base = header.cursor_base.saturating_add(current_len); + atomic_write_event_log(&path, cursor_base, Some(&encoded))?; + return Ok(cursor_base + .saturating_add(encoded.len() as u64) + .saturating_add(1)); + } + file.write_all(&encoded)?; + file.write_all(b"\n")?; + file.sync_data()?; + Ok(header + .cursor_base + .saturating_add(file.metadata()?.len().saturating_sub(data_start))) + } + + fn existing_job_dir(&self, job_id: &str) -> Result { + let path = self.job_dir(job_id)?; + let metadata = fs::symlink_metadata(&path) + .with_context(|| format!("dispatch job not found: {job_id}"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("dispatch job path is not a private directory: {job_id}"); + } + let record_path = path.join(JOB_RECORD_FILE); + let record_metadata = fs::symlink_metadata(&record_path) + .with_context(|| format!("dispatch job is not committed: {job_id}"))?; + if record_metadata.file_type().is_symlink() || !record_metadata.is_file() { + bail!("dispatch job commit marker is not a regular file: {job_id}"); + } + Ok(path) + } + + fn job_dir(&self, job_id: &str) -> Result { + validate_id("jobId", job_id)?; + Ok(self.root.join("jobs").join(job_id)) + } + + #[cfg(test)] + fn open_with_event_limit(root: PathBuf, max_events_bytes: u64) -> Result { + let mut store = Self::open(root)?; + store.max_events_bytes = max_events_bytes; + Ok(store) + } +} + +pub(crate) struct WorkspaceLock { + _file: File, +} + +impl WorkspaceLock { + pub(crate) fn acquire(path: &Path) -> Result { + if let Some(parent) = path.parent() { + create_private_dir(parent)?; + } + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(path) + .with_context(|| format!("open workspace dispatch lock {}", path.display()))?; + set_private_file_permissions(path)?; + FileLock::exclusive(&file)?; + Ok(Self { _file: file }) + } +} + +pub(crate) struct DispatchLease { + _file: File, +} + +impl DispatchLease { + fn try_acquire(path: &Path) -> Result> { + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(path) + .with_context(|| format!("open dispatch lease {}", path.display()))?; + set_private_file_permissions(path)?; + try_lock_file_exclusive(&file).map(|acquired| acquired.then_some(Self { _file: file })) + } +} + +struct JobLock { + _file: File, +} + +impl JobLock { + fn exclusive(path: &Path) -> Result { + Self::open(path, true) + } + + fn shared(path: &Path) -> Result { + Self::open(path, false) + } + + fn open(path: &Path, exclusive: bool) -> Result { + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(path) + .with_context(|| format!("open dispatch job lock {}", path.display()))?; + set_private_file_permissions(path)?; + if exclusive { + FileLock::exclusive(&file)?; + } else { + FileLock::shared(&file)?; + } + Ok(Self { _file: file }) + } +} + +struct FileLock; + +impl FileLock { + fn exclusive(file: &File) -> Result { + lock_file(file, true)?; + Ok(Self) + } + + fn shared(file: &File) -> Result { + lock_file(file, false)?; + Ok(Self) + } +} + +fn validate_id(field: &str, value: &str) -> Result<()> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + || value == "." + || value == ".." + { + bail!( + "{field} must be 1-128 ASCII letters, digits, '.', '_' or '-' without path separators" + ); + } + Ok(()) +} + +fn submit_intent_fingerprint(request: &DispatchSubmitRequest) -> Result { + use sha2::{Digest, Sha256}; + let encoded = serde_json::to_vec(request).context("encode dispatch submit intent")?; + Ok(format!("{:x}", Sha256::digest(encoded))) +} + +fn read_json Deserialize<'de>>(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_slice(&bytes).with_context(|| format!("decode {}", path.display())) +} + +fn read_event_log_header(file: &mut File, path: &Path) -> Result<(EventLogHeader, u64)> { + file.seek(SeekFrom::Start(0))?; + let mut bytes = Vec::with_capacity(64); + let mut next = [0_u8; 1]; + loop { + if bytes.len() >= 4 * 1024 { + bail!("dispatch event log header is too large: {}", path.display()); + } + let read = file.read(&mut next)?; + if read == 0 { + bail!( + "dispatch event log header is incomplete: {}", + path.display() + ); + } + if next[0] == b'\n' { + break; + } + bytes.push(next[0]); + } + let header = serde_json::from_slice(&bytes) + .with_context(|| format!("decode dispatch event log header {}", path.display()))?; + Ok((header, bytes.len() as u64 + 1)) +} + +fn atomic_write_event_log(path: &Path, cursor_base: u64, event: Option<&[u8]>) -> Result<()> { + let mut bytes = serde_json::to_vec(&EventLogHeader { cursor_base }) + .context("encode dispatch event log header")?; + bytes.push(b'\n'); + if let Some(event) = event { + bytes.extend_from_slice(event); + bytes.push(b'\n'); + } + atomic_write(path, &bytes) +} + +fn truncate_incomplete_event_tail(file: &mut File) -> Result { + let len = file.metadata()?.len(); + if len == 0 { + return Ok(0); + } + file.seek(SeekFrom::End(-1))?; + let mut last = [0_u8; 1]; + file.read_exact(&mut last)?; + if last[0] == b'\n' { + return Ok(len); + } + + file.seek(SeekFrom::Start(0))?; + let mut bytes = Vec::with_capacity(len.min(DEFAULT_MAX_EVENTS_BYTES) as usize); + file.read_to_end(&mut bytes)?; + let retained = bytes + .iter() + .rposition(|byte| *byte == b'\n') + .map(|index| index + 1) + .unwrap_or(0); + file.set_len(retained as u64)?; + file.seek(SeekFrom::Start(retained as u64))?; + file.sync_data()?; + Ok(retained as u64) +} + +fn atomic_write_json(path: &Path, value: &impl Serialize) -> Result<()> { + let mut bytes = serde_json::to_vec_pretty(value).context("encode dispatch state")?; + bytes.push(b'\n'); + atomic_write(path, &bytes) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("dispatch state path has no parent: {}", path.display()))?; + create_private_dir(parent)?; + let temp = parent.join(format!( + ".{}.{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("dispatch"), + uuid::Uuid::new_v4() + )); + let result = (|| -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .with_context(|| format!("create temporary dispatch file {}", temp.display()))?; + set_private_file_permissions(&temp)?; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&temp, path) + .with_context(|| format!("publish dispatch file {}", path.display()))?; + set_private_file_permissions(path)?; + sync_directory(parent)?; + Ok(()) + })(); + if result.is_err() { + remove_file_if_present(&temp); + } + result +} + +fn ensure_private_file(path: &Path) -> Result<()> { + let file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("create dispatch file {}", path.display()))?; + drop(file); + set_private_file_permissions(path) +} + +fn create_private_dir(path: &Path) -> Result<()> { + fs::create_dir_all(path).with_context(|| format!("create {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .with_context(|| format!("set private permissions on {}", path.display()))?; + } + Ok(()) +} + +fn set_private_file_permissions(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("set private permissions on {}", path.display()))?; + } + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +fn sync_directory(path: &Path) -> Result<()> { + #[cfg(unix)] + { + File::open(path) + .with_context(|| format!("open dispatch directory {}", path.display()))? + .sync_all() + .with_context(|| format!("sync dispatch directory {}", path.display()))?; + } + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +fn remove_file_if_present(path: &Path) { + if let Err(error) = fs::remove_file(path) { + if error.kind() != std::io::ErrorKind::NotFound { + tracing::warn!("Failed to remove dispatch file {}: {error}", path.display()); + } + } +} + +#[cfg(unix)] +fn lock_file(file: &File, exclusive: bool) -> Result<()> { + use std::os::fd::AsRawFd; + let operation = if exclusive { + libc::LOCK_EX + } else { + libc::LOCK_SH + }; + // SAFETY: flock only operates on this live file descriptor. + if unsafe { libc::flock(file.as_raw_fd(), operation) } == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()).context("lock dispatch file") + } +} + +#[cfg(unix)] +fn try_lock_file_exclusive(file: &File) -> Result { + use std::os::fd::AsRawFd; + // SAFETY: flock only operates on this live file descriptor. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(true); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + Ok(false) + } else { + Err(error).context("try lock dispatch file") + } +} + +#[cfg(not(unix))] +fn lock_file(_file: &File, _exclusive: bool) -> Result<()> { + Ok(()) +} + +#[cfg(not(unix))] +fn try_lock_file_exclusive(_file: &File) -> Result { + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dispatch::protocol::{DispatchApprovalPolicy, DispatchSubmitRequest}; + + fn request(job_id: &str) -> DispatchSubmitRequest { + DispatchSubmitRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: job_id.to_string(), + session_id: format!("session-{job_id}"), + workspace_path: "/tmp/workspace".to_string(), + agent_type: "agentic".to_string(), + prompt: "do the work".to_string(), + approval_policy: DispatchApprovalPolicy::RejectAndReport, + model: Some("model-1".to_string()), + title: None, + } + } + + fn store() -> (tempfile::TempDir, DispatchStore) { + let dir = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(dir.path().join("dispatch")).expect("store"); + (dir, store) + } + + #[test] + fn event_cursor_is_monotonic_and_does_not_replay() { + let (_dir, store) = store(); + store + .create_job(request("job-1"), "Task".to_string()) + .expect("create job"); + + let first = store.read_events("job-1", 0).expect("first page"); + assert_eq!(first.events.len(), 2); + assert!(first.cursor > 0); + + store + .append_event( + "job-1", + &DispatchEvent::job_state(DispatchJobState::Running, Some("started".to_string())), + ) + .expect("append"); + let second = store + .read_events("job-1", first.cursor) + .expect("second page"); + assert_eq!(second.events.len(), 1); + assert!(second.cursor > first.cursor); + + let empty = store + .read_events("job-1", second.cursor) + .expect("empty page"); + assert!(empty.events.is_empty()); + assert_eq!(empty.cursor, second.cursor); + } + + #[test] + fn incomplete_trailing_event_is_retried_after_crash_recovery() { + let (_dir, store) = store(); + store + .create_job(request("job-2"), "Task".to_string()) + .expect("create job"); + let initial = store.read_events("job-2", 0).expect("initial page"); + let path = store.job_dir("job-2").expect("job dir").join(EVENTS_FILE); + let mut file = OpenOptions::new() + .append(true) + .open(&path) + .expect("open events"); + file.write_all(br#"{"type":"jobState","timestamp":"partial""#) + .expect("write partial line"); + file.sync_all().expect("sync partial line"); + + let page = store + .read_events("job-2", initial.cursor) + .expect("read after partial write"); + assert!(page.events.is_empty()); + assert_eq!(page.cursor, initial.cursor); + + store + .append_event( + "job-2", + &DispatchEvent::job_state(DispatchJobState::Running, Some("recovered".to_string())), + ) + .expect("append after partial write"); + let recovered = store + .read_events("job-2", initial.cursor) + .expect("read recovered event"); + assert_eq!(recovered.events.len(), 1); + assert!(matches!( + &recovered.events[0], + DispatchEvent::JobState { + state: DispatchJobState::Running, + .. + } + )); + assert!(recovered.cursor > initial.cursor); + } + + #[test] + fn terminal_state_is_idempotent() { + let (_dir, store) = store(); + store + .create_job(request("job-3"), "Task".to_string()) + .expect("create job"); + let (succeeded, changed) = store + .mark_state("job-3", DispatchJobState::Succeeded, Some("turn-1"), None) + .expect("succeed"); + assert!(changed); + assert_eq!(succeeded.state, DispatchJobState::Succeeded); + + let (still_succeeded, changed) = store + .mark_state( + "job-3", + DispatchJobState::Failed, + Some("turn-1"), + Some("late failure".to_string()), + ) + .expect("late terminal update"); + assert!(!changed); + assert_eq!(still_succeeded.state, DispatchJobState::Succeeded); + assert!(still_succeeded.last_error.is_none()); + } + + #[test] + fn worker_exit_settlement_observes_cancel_request_under_the_state_lock() { + let (_dir, store) = store(); + store + .create_job(request("job-cancel-exit"), "Task".to_string()) + .expect("create cancelled job"); + store + .request_cancel("job-cancel-exit") + .expect("request cancellation"); + assert_eq!( + store + .settle_exited_worker("job-cancel-exit") + .expect("settle cancelled worker") + .state, + DispatchJobState::Cancelled + ); + + store + .create_job(request("job-crash-exit"), "Task".to_string()) + .expect("create crashed job"); + let crashed = store + .settle_exited_worker("job-crash-exit") + .expect("settle crashed worker"); + assert_eq!(crashed.state, DispatchJobState::Failed); + assert!(crashed.last_error.is_some()); + } + + #[test] + fn duplicate_submit_is_idempotent_but_conflicts_fail() { + let (_dir, store) = store(); + let original = request("job-4"); + assert!(matches!( + store + .create_job(original.clone(), "Task".to_string()) + .expect("first"), + CreateJobOutcome::Created(_) + )); + assert!(matches!( + store + .create_job(original.clone(), "Task".to_string()) + .expect("duplicate"), + CreateJobOutcome::Existing(_) + )); + + let mut conflicting = original; + conflicting.prompt = "different task".to_string(); + assert!(store.create_job(conflicting, "Task".to_string()).is_err()); + } + + #[test] + fn retry_rebuilds_uncommitted_partial_job_before_publishing_record() { + let (_dir, store) = store(); + let request = request("job-partial-create"); + let job_dir = store.job_dir("job-partial-create").expect("job dir"); + create_private_dir(&job_dir).expect("partial job dir"); + atomic_write(&job_dir.join(STATE_FILE), b"{\"state\":").expect("partial state artifact"); + ensure_private_file(&job_dir.join(EVENTS_LOCK_FILE)).expect("partial event lock"); + atomic_write( + &job_dir.join(EVENTS_FILE), + b"{\"cursorBase\":0}\n{\"type\":", + ) + .expect("partial event artifact"); + assert!(!job_dir.join(JOB_RECORD_FILE).exists()); + assert!( + store.load_state("job-partial-create").is_err(), + "status must not consume an uncommitted partial state" + ); + assert!( + store.read_events("job-partial-create", 0).is_err(), + "status must not consume an uncommitted partial event stream" + ); + assert!( + store.request_cancel("job-partial-create").is_err(), + "cancel must not mutate an uncommitted partial job" + ); + assert!( + store + .load_existing_job_for_intent(&request) + .expect("lookup uncommitted partial job") + .is_none(), + "an exact retry must be allowed to rebuild an uncommitted partial job" + ); + + let outcome = store + .create_job(request, "Recovered task".to_string()) + .expect("retry partial initialization"); + assert!(matches!(outcome, CreateJobOutcome::Created(_))); + assert!( + job_dir.join(JOB_RECORD_FILE).is_file(), + "job record is published only after the artifacts are rebuilt" + ); + assert_eq!( + store + .load_state("job-partial-create") + .expect("recovered state") + .state, + DispatchJobState::Queued + ); + let events = store + .read_events("job-partial-create", 0) + .expect("recovered events"); + assert_eq!(events.events.len(), 2); + assert!(matches!( + events.events.first(), + Some(DispatchEvent::Audit { action, .. }) if action == "approvalPolicySelected" + )); + } + + #[test] + fn raw_submit_intent_remains_idempotent_when_resolution_changes() { + let (_dir, store) = store(); + let mut intent = request("job-stable-intent"); + intent.model = None; + intent.title = None; + intent.workspace_path = "/symbolic/workspace".to_string(); + let mut resolved_a = intent.clone(); + resolved_a.model = Some("model-a".to_string()); + resolved_a.title = Some("Generated title A".to_string()); + resolved_a.workspace_path = "/canonical/workspace-a".to_string(); + store + .create_job_with_intent(intent.clone(), resolved_a, "Generated title A".to_string()) + .expect("first resolved submit"); + + let mut resolved_b = intent.clone(); + resolved_b.model = Some("model-b".to_string()); + resolved_b.title = Some("Generated title B".to_string()); + resolved_b.workspace_path = "/canonical/workspace-b".to_string(); + assert!(matches!( + store + .create_job_with_intent(intent.clone(), resolved_b, "Generated title B".to_string()) + .expect("same raw intent"), + CreateJobOutcome::Existing(_) + )); + let (record, _) = store + .load_existing_job_for_intent(&intent) + .expect("lookup") + .expect("existing job"); + assert_eq!(record.request.model.as_deref(), Some("model-a")); + assert_eq!(record.request.workspace_path, "/canonical/workspace-a"); + + let mut conflicting_intent = intent; + conflicting_intent.prompt = "different task".to_string(); + assert!(store + .load_existing_job_for_intent(&conflicting_intent) + .is_err()); + } + + #[test] + fn queued_job_spawn_claim_recovers_after_controller_loss() { + let (_dir, store) = store(); + store + .create_job(request("job-spawn-retry"), "Task".to_string()) + .expect("create job"); + + let first = store + .try_claim_worker_spawn("job-spawn-retry") + .expect("first claim") + .expect("claim available"); + assert!( + store + .try_claim_worker_spawn("job-spawn-retry") + .expect("contended claim") + .is_none(), + "a concurrent idempotent submit must not spawn twice" + ); + drop(first); + assert!( + store + .try_claim_worker_spawn("job-spawn-retry") + .expect("recovery claim") + .is_some(), + "the OS lock must release after controller loss so a retry can recover the queued job" + ); + } + + #[test] + fn worker_lease_allows_only_one_executor_per_job() { + let (_dir, store) = store(); + store + .create_job(request("job-worker-lease"), "Task".to_string()) + .expect("create job"); + + let first = store + .try_acquire_worker_lease("job-worker-lease") + .expect("first lease") + .expect("lease available"); + assert!(store + .try_acquire_worker_lease("job-worker-lease") + .expect("contended lease") + .is_none()); + drop(first); + assert!(store + .try_acquire_worker_lease("job-worker-lease") + .expect("released lease") + .is_some()); + } + + #[test] + fn first_event_audits_only_the_explicit_approval_policy() { + let (_dir, store) = store(); + store + .create_job(request("job-audit"), "Task".to_string()) + .expect("create job"); + let page = store.read_events("job-audit", 0).expect("events"); + let DispatchEvent::Audit { + action, details, .. + } = &page.events[0] + else { + panic!("first event must be an audit row"); + }; + assert_eq!(action, "approvalPolicySelected"); + assert_eq!(details["approvalPolicy"], "reject-and-report"); + assert!(details.get("prompt").is_none()); + } + + #[test] + fn cursor_beyond_the_file_resets_to_the_retained_prefix() { + let (_dir, store) = store(); + store + .create_job(request("job-5"), "Task".to_string()) + .expect("create job"); + let page = store.read_events("job-5", u64::MAX).expect("reset page"); + assert!(page.cursor_reset); + assert_eq!(page.events.len(), 2); + } + + #[test] + fn atomic_rotation_resets_old_cursors_and_keeps_terminal_state_visible() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = + DispatchStore::open_with_event_limit(dir.path().join("dispatch"), 512).expect("store"); + store + .create_job(request("job-6"), "Task".to_string()) + .expect("create job"); + let before = store.read_events("job-6", 0).expect("before rotation"); + let job_dir = store.job_dir("job-6").expect("job dir"); + let events_path = job_dir.join(EVENTS_FILE); + store + .append_event( + "job-6", + &DispatchEvent::job_state( + DispatchJobState::Running, + Some(format!("rotation-event-{}", "x".repeat(320))), + ), + ) + .expect("rotate event log"); + let rotated = store.read_events("job-6", 0).expect("after rotation"); + assert!(rotated.cursor_reset); + assert!(rotated.cursor > before.cursor); + assert_eq!(rotated.events.len(), 1); + + let mut file = File::open(&events_path).expect("open rotated log"); + let (header, data_start) = + read_event_log_header(&mut file, &events_path).expect("coherent header"); + assert!(header.cursor_base >= before.cursor); + assert!(file.metadata().expect("metadata").len() > data_start); + + // A crash before the final rename may leave an unpublished temporary + // file, but readers continue to observe the complete active artifact. + let active_bytes = fs::read(&events_path).expect("active rotated log"); + fs::write(job_dir.join(".events.crash.tmp"), b"incomplete replacement") + .expect("simulate pre-publish crash"); + assert_eq!( + fs::read(&events_path).expect("active log after simulated crash"), + active_bytes + ); + let caught_up = store + .read_events("job-6", rotated.cursor) + .expect("read after simulated crash"); + assert!(caught_up.events.is_empty()); + assert_eq!(caught_up.cursor, rotated.cursor); + + store + .mark_state("job-6", DispatchJobState::Succeeded, Some("turn-1"), None) + .expect("write terminal state after rotation"); + let terminal = store.read_events("job-6", 0).expect("terminal page"); + assert!(terminal.cursor_reset); + assert!(terminal.events.iter().any(|event| matches!( + event, + DispatchEvent::JobState { + state: DispatchJobState::Succeeded, + .. + } + ))); + } + + #[cfg(unix)] + #[test] + fn cross_process_reader_and_writer_remain_consistent_during_rotation() { + const MODE_ENV: &str = "BITFUN_DISPATCH_ROTATION_STRESS_MODE"; + const ROOT_ENV: &str = "BITFUN_DISPATCH_ROTATION_STRESS_ROOT"; + const DONE_ENV: &str = "BITFUN_DISPATCH_ROTATION_STRESS_DONE"; + + if let Some(mode) = std::env::var_os(MODE_ENV) { + let root = PathBuf::from(std::env::var_os(ROOT_ENV).expect("stress root")); + let done = PathBuf::from(std::env::var_os(DONE_ENV).expect("stress done")); + let store = DispatchStore::open_with_event_limit(root, 4 * 1024).expect("child store"); + match mode.to_string_lossy().as_ref() { + "writer" => { + for index in 0..240 { + store + .append_event( + "job-stress", + &DispatchEvent::job_state( + DispatchJobState::Running, + Some(format!("{index}:{}", "x".repeat(512))), + ), + ) + .expect("stress append"); + } + fs::write(done, b"done\n").expect("publish writer completion"); + } + "reader" => { + let mut cursor = 0_u64; + let mut empty_after_done = 0_u8; + for _ in 0..10_000 { + let page = store + .read_events("job-stress", cursor) + .expect("stress read"); + assert!(page.cursor >= cursor, "absolute cursor must not regress"); + cursor = page.cursor; + if done.exists() && page.events.is_empty() { + empty_after_done += 1; + if empty_after_done >= 3 { + return; + } + } else { + empty_after_done = 0; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + panic!("reader did not drain the rotated log"); + } + other => panic!("unexpected stress mode {other}"), + } + return; + } + + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().join("dispatch"); + let done = dir.path().join("writer.done"); + DispatchStore::open_with_event_limit(root.clone(), 4 * 1024) + .expect("parent store") + .create_job(request("job-stress"), "Task".to_string()) + .expect("create stress job"); + let executable = std::env::current_exe().expect("test executable"); + let test_name = + "dispatch::store::tests::cross_process_reader_and_writer_remain_consistent_during_rotation"; + let mut reader = std::process::Command::new(&executable) + .args(["--exact", test_name, "--nocapture"]) + .env(MODE_ENV, "reader") + .env(ROOT_ENV, &root) + .env(DONE_ENV, &done) + .spawn() + .expect("spawn stress reader"); + let writer = std::process::Command::new(&executable) + .args(["--exact", test_name, "--nocapture"]) + .env(MODE_ENV, "writer") + .env(ROOT_ENV, &root) + .env(DONE_ENV, &done) + .output() + .expect("run stress writer"); + let reader_status = reader.wait().expect("wait for stress reader"); + assert!( + writer.status.success(), + "writer failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&writer.stdout), + String::from_utf8_lossy(&writer.stderr) + ); + assert!(reader_status.success(), "stress reader failed"); + } + + #[test] + fn status_pages_are_bounded_and_continue_from_the_returned_cursor() { + let (_dir, store) = store(); + store + .create_job(request("job-page"), "Task".to_string()) + .expect("create job"); + for index in 0..48 { + store + .append_event( + "job-page", + &DispatchEvent::job_state( + DispatchJobState::Running, + Some(format!("{index}:{}", "x".repeat(64 * 1024))), + ), + ) + .expect("append page event"); + } + let first = store.read_events("job-page", 0).expect("first page"); + assert!(first.events.len() < 50); + assert!(first.cursor <= MAX_STATUS_PAGE_BYTES); + assert!( + serde_json::to_vec(&first.events) + .expect("serialize status events") + .len() + <= MAX_STATUS_PAGE_BYTES as usize + 1 + ); + let second = store + .read_events("job-page", first.cursor) + .expect("second page"); + assert!(!second.events.is_empty()); + assert!(second.cursor > first.cursor); + } + + #[test] + fn oversized_single_event_is_replaced_without_failing_the_job_log() { + let (_dir, store) = store(); + store + .create_job(request("job-large-event"), "Task".to_string()) + .expect("create job"); + let before = store + .read_events("job-large-event", 0) + .expect("initial events"); + store + .append_event( + "job-large-event", + &DispatchEvent::job_state( + DispatchJobState::Running, + Some("x".repeat(MAX_EVENT_BYTES)), + ), + ) + .expect("replace oversized event"); + let page = store + .read_events("job-large-event", before.cursor) + .expect("oversized marker"); + assert_eq!(page.events.len(), 1); + let DispatchEvent::Audit { + action, details, .. + } = &page.events[0] + else { + panic!("oversized event must become an audit marker"); + }; + assert_eq!(action, "eventOmitted"); + assert_eq!(details["reason"], "eventTooLarge"); + assert_eq!(details["maxBytes"], MAX_EVENT_BYTES); + } + + #[test] + fn status_pages_cap_event_count_without_skipping_cursor_bytes() { + let (_dir, store) = store(); + store + .create_job(request("job-event-cap"), "Task".to_string()) + .expect("create job"); + for index in 0..1_023 { + store + .append_event( + "job-event-cap", + &DispatchEvent::job_state( + DispatchJobState::Running, + Some(format!("event-{index}")), + ), + ) + .expect("append event"); + } + + let first = store.read_events("job-event-cap", 0).expect("first page"); + assert_eq!(first.events.len(), MAX_STATUS_PAGE_EVENTS); + let second = store + .read_events("job-event-cap", first.cursor) + .expect("second page"); + assert_eq!(second.events.len(), MAX_STATUS_PAGE_EVENTS); + let third = store + .read_events("job-event-cap", second.cursor) + .expect("third page"); + assert_eq!(third.events.len(), 1); + let end = store + .read_events("job-event-cap", third.cursor) + .expect("end page"); + assert!(end.events.is_empty()); + assert!(first.cursor < second.cursor); + assert!(second.cursor < third.cursor); + assert_eq!(third.cursor, end.cursor); + assert_eq!( + first.events.len() + second.events.len() + third.events.len(), + 1_025, + "the two initial events plus every appended event must be returned exactly once" + ); + } + + #[test] + fn default_store_honors_path_manager_storage_overrides() { + const CHILD_ENV: &str = "BITFUN_DISPATCH_PATH_TEST_CHILD"; + if let Some(expected_home) = std::env::var_os(CHILD_ENV) { + let store = DispatchStore::open_default().expect("open isolated default store"); + assert_eq!(store.root, PathBuf::from(expected_home).join("dispatch")); + return; + } + + let dir = tempfile::tempdir().expect("tempdir"); + let bitfun_home = dir.path().join("bitfun-home"); + let user_root = dir.path().join("user-root"); + let output = std::process::Command::new(std::env::current_exe().expect("test executable")) + .args([ + "--exact", + "dispatch::store::tests::default_store_honors_path_manager_storage_overrides", + "--nocapture", + ]) + .env(CHILD_ENV, &bitfun_home) + .env("BITFUN_HOME", &bitfun_home) + .env("BITFUN_USER_ROOT", &user_root) + .env("BITFUN_E2E_STORAGE_GUARD", "1") + .env_remove("BITFUN_E2E_HOME") + .env_remove("BITFUN_E2E_USER_ROOT") + .output() + .expect("run isolated path test"); + assert!( + output.status.success(), + "isolated child failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(bitfun_home.join("dispatch/jobs").is_dir()); + assert!(bitfun_home.join("dispatch/workspaces").is_dir()); + } + + #[cfg(unix)] + #[test] + fn job_storage_uses_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + + let (_dir, store) = store(); + store + .create_job(request("job-private"), "Task".to_string()) + .expect("create job"); + let job_dir = store.job_dir("job-private").expect("job dir"); + assert_eq!( + fs::metadata(&job_dir) + .expect("job metadata") + .permissions() + .mode() + & 0o777, + 0o700 + ); + for file in [JOB_RECORD_FILE, STATE_FILE, EVENTS_FILE, EVENTS_LOCK_FILE] { + assert_eq!( + fs::metadata(job_dir.join(file)) + .expect("file metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } +} diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs new file mode 100644 index 0000000000..43cd6436b9 --- /dev/null +++ b/src/apps/cli/src/dispatch/worker.rs @@ -0,0 +1,497 @@ +use std::collections::{HashSet, VecDeque}; +use std::path::Path; + +use anyhow::{anyhow, bail, Context, Result}; +use bitfun_agent_runtime::sdk::{ + AgentDialogTurnRequest, AgentSessionCreateRequest, AgentTurnCancellationRequest, + AgentTurnSettlementRequest, PermissionReply, PermissionReplySource, PermissionRequest, + PermissionRequestEvent, +}; +use bitfun_events::{project_agentic_frontend_event, AgenticEvent}; +use bitfun_runtime_ports::{AgentSubmissionSource, DialogSubmissionPolicy, SessionExecutionTarget}; + +use crate::{shutdown_mcp_servers, BootstrapProfile}; + +use super::permissions::{self, REJECT_AND_REPORT_REASON}; +use super::protocol::{DispatchApprovalPolicy, DispatchEvent, DispatchJobState}; +use super::store::{DispatchStore, WorkspaceLock}; + +const TURN_SETTLEMENT_TIMEOUT_MS: u64 = 5_000; + +pub(crate) async fn run(job_id: String) -> Result<()> { + let store = DispatchStore::open_default()?; + let Some(_worker_lease) = store.try_acquire_worker_lease(&job_id)? else { + // A retry may briefly spawn a duplicate after its controller crashes. + // Only the lease holder may publish a PID or execute the job. + return Ok(()); + }; + let worker_pid = std::process::id(); + store.write_pid(&job_id, worker_pid)?; + store.clear_preparing(&job_id); + let result = run_inner(&store, &job_id).await; + if let Err(error) = &result { + let _ = store.mark_state( + &job_id, + DispatchJobState::Failed, + None, + Some(format!("{error:#}")), + ); + } + store.clear_preparing(&job_id); + store.remove_pid_if_matches(&job_id, worker_pid); + shutdown_mcp_servers().await; + result +} + +async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { + let job = store.load_job(job_id)?; + let state = store.load_state(job_id)?; + if state.state.is_terminal() { + return Ok(()); + } + if state.cancel_requested() { + store.mark_state( + job_id, + DispatchJobState::Cancelled, + state.turn_id.as_deref(), + Some("Dispatch worker observed a cancellation request".to_string()), + )?; + return Ok(()); + } + if state.turn_id.is_some() { + bail!( + "dispatch worker cannot replay an already-submitted turn after process loss; session {} remains available on the target", + job.request.session_id + ); + } + + let workspace = Path::new(&job.request.workspace_path); + if !workspace.is_absolute() { + bail!("dispatch workspacePath must be absolute"); + } + if !workspace.is_dir() { + bail!( + "dispatch workspace does not exist or is not a directory: {}", + workspace.display() + ); + } + super::ensure_selected_model_ready(job.request.model.as_deref()).await?; + + // Every detached worker takes the same stable lock for a canonical target + // workspace. Waiting workers remain Queued and are visible/cancellable. + let lock_path = store.workspace_lock_path(&job.request.workspace_path); + let _workspace_lock = WorkspaceLock::acquire(&lock_path)?; + let state = store.load_state(job_id)?; + if state.state.is_terminal() { + return Ok(()); + } + if state.cancel_requested() { + store.mark_state( + job_id, + DispatchJobState::Cancelled, + state.turn_id.as_deref(), + Some("Dispatch worker observed a cancellation request".to_string()), + )?; + return Ok(()); + } + store.mark_state(job_id, DispatchJobState::Running, None, None)?; + + let runtime = crate::initialize_core_services( + workspace, + permissions::cli_policy(job.request.approval_policy), + BootstrapProfile::Execution, + ) + .await?; + let agent_runtime = runtime.agent_runtime().clone(); + let mut event_rx = agent_runtime + .subscribe_events() + .map_err(|error| anyhow!(error.into_message()))?; + let mut permission_rx = agent_runtime + .subscribe_permission_requests() + .map_err(|error| anyhow!(error.into_message()))?; + + let workspace_path = job.request.workspace_path.clone(); + agent_runtime + .create_session_with_id( + job.request.session_id.clone(), + AgentSessionCreateRequest { + session_name: job.title.clone(), + agent_type: job.request.agent_type.clone(), + workspace_path: Some(workspace_path.clone()), + project_workspace_path: Some(workspace_path.clone()), + execution_target: Some(SessionExecutionTarget::local(workspace_path.clone())), + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + model_id: job.request.model.clone(), + metadata: serde_json::Map::new(), + }, + ) + .await + .map_err(|error| anyhow!(error.into_message())) + .context("create target-owned dispatch session")?; + + let turn_id = uuid::Uuid::new_v4().to_string(); + // Persist the deterministic turn id before submission. A crash after the + // Runtime accepts the turn must never make a replacement worker submit the + // prompt a second time. + store.record_turn_id(job_id, &turn_id)?; + agent_runtime + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: job.request.session_id.clone(), + message: job.request.prompt.clone(), + original_message: None, + turn_id: Some(turn_id.clone()), + agent_type: job.request.agent_type.clone(), + workspace_path: Some(workspace_path), + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: Vec::new(), + metadata: permissions::metadata(job.request.approval_policy), + }) + .await + .map_err(|error| anyhow!(error.into_message())) + .context("submit dispatch dialog turn")?; + + let mut initial_permissions = agent_runtime + .pending_permission_requests() + .unwrap_or_default() + .into_iter() + .collect::>(); + let mut handled_permissions = HashSet::new(); + + let (terminal_state, terminal_error) = loop { + if let Some(request) = initial_permissions.pop_front() { + if permission_targets_job(&request, &job.request.session_id) + && handled_permissions.insert(request.request_id.clone()) + { + let reason = reject_permission( + store, + job_id, + &agent_runtime, + &job.request.session_id, + &turn_id, + request, + job.request.approval_policy, + ) + .await?; + break (DispatchJobState::Failed, Some(reason)); + } + continue; + } + + tokio::select! { + received = event_rx.recv() => { + let envelope = match received { + Ok(envelope) => envelope, + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + cancel_turn(&agent_runtime, &job.request.session_id, &turn_id, "dispatch_event_stream_lagged").await; + break ( + DispatchJobState::Failed, + Some(format!("dispatch event stream lost {skipped} events; the turn was cancelled")), + ); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + cancel_turn(&agent_runtime, &job.request.session_id, &turn_id, "dispatch_event_stream_closed").await; + break ( + DispatchJobState::Failed, + Some("dispatch event stream closed before the turn settled".to_string()), + ); + } + }; + if !event_belongs_to_job(&envelope.event, &job.request.session_id, &turn_id) { + continue; + } + let projection = project_agentic_frontend_event(envelope.event.clone()) + .map(|projected| (projected.event_name, projected.payload)); + let raw = serde_json::to_value(&envelope) + .context("serialize dispatch Agent event")?; + store.append_event( + job_id, + &DispatchEvent::agent_event(raw, projection), + )?; + if let Some(outcome) = terminal_outcome(&envelope.event, &turn_id) { + break outcome; + } + } + received = permission_rx.recv() => { + let event = match received { + Ok(event) => event, + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + cancel_turn(&agent_runtime, &job.request.session_id, &turn_id, "dispatch_permission_stream_lagged").await; + break ( + DispatchJobState::Failed, + Some(format!("dispatch permission stream lost {skipped} requests; the turn was cancelled")), + ); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + cancel_turn(&agent_runtime, &job.request.session_id, &turn_id, "dispatch_permission_stream_closed").await; + break ( + DispatchJobState::Failed, + Some("dispatch permission stream closed before the turn settled".to_string()), + ); + } + }; + let PermissionRequestEvent::Asked { request } = event else { + continue; + }; + if !permission_targets_job(&request, &job.request.session_id) + || !handled_permissions.insert(request.request_id.clone()) + { + continue; + } + let reason = reject_permission( + store, + job_id, + &agent_runtime, + &job.request.session_id, + &turn_id, + request, + job.request.approval_policy, + ) + .await?; + break (DispatchJobState::Failed, Some(reason)); + } + } + }; + + let settlement = agent_runtime + .wait_for_turn_settlement(AgentTurnSettlementRequest { + session_id: job.request.session_id.clone(), + turn_id: turn_id.clone(), + wait_timeout_ms: TURN_SETTLEMENT_TIMEOUT_MS, + }) + .await; + let (terminal_state, terminal_error) = match settlement { + Ok(()) => (terminal_state, terminal_error), + Err(error) => ( + DispatchJobState::Failed, + Some(format!( + "dispatch turn reached a terminal event but did not settle: {}", + error.into_message() + )), + ), + }; + store.mark_state(job_id, terminal_state, Some(&turn_id), terminal_error)?; + Ok(()) +} + +async fn reject_permission( + store: &DispatchStore, + job_id: &str, + runtime: &bitfun_agent_runtime::sdk::AgentRuntime, + session_id: &str, + turn_id: &str, + request: PermissionRequest, + policy: DispatchApprovalPolicy, +) -> Result { + let reason = match policy { + DispatchApprovalPolicy::RejectAndReport => REJECT_AND_REPORT_REASON.to_string(), + DispatchApprovalPolicy::Auto => format!( + "Dispatch Auto policy could not safely auto-approve permission request {}", + request.request_id + ), + }; + store.append_event( + job_id, + &DispatchEvent::permission_rejected( + serde_json::to_value(&request).context("serialize permission request")?, + reason.clone(), + ), + )?; + runtime + .respond_permission_with_source( + &request.request_id, + PermissionReply::Reject { + feedback: Some(reason.clone()), + }, + PermissionReplySource::System, + ) + .await + .map_err(|error| anyhow!(error.into_message())) + .context("reject unattended dispatch permission")?; + cancel_turn(runtime, session_id, turn_id, "dispatch_permission_rejected").await; + Ok(reason) +} + +async fn cancel_turn( + runtime: &bitfun_agent_runtime::sdk::AgentRuntime, + session_id: &str, + turn_id: &str, + reason: &str, +) { + if let Err(error) = runtime + .cancel_turn(AgentTurnCancellationRequest { + session_id: session_id.to_string(), + turn_id: Some(turn_id.to_string()), + source: Some(AgentSubmissionSource::Cli), + requester_session_id: None, + reason: Some(reason.to_string()), + wait_timeout_ms: None, + }) + .await + { + tracing::error!("Failed to cancel dispatch turn: {}", error.into_message()); + } +} + +fn permission_targets_job(request: &PermissionRequest, session_id: &str) -> bool { + crate::runtime::approval::permission_request_targets_session(request, session_id) +} + +fn event_belongs_to_job(event: &AgenticEvent, session_id: &str, turn_id: &str) -> bool { + if matches!(event, AgenticEvent::SubagentSessionLinked { .. }) { + // Phase 1 has no child-session observer or dispatch marker. Publishing + // this link would create an empty local-looking child in the Web UI, + // while every later child event is correctly outside the parent scope. + return false; + } + if event + .session_id() + .is_some_and(|event_session| event_session != session_id) + { + return false; + } + event_turn_id(event).is_none_or(|event_turn| event_turn == turn_id) +} + +fn event_turn_id(event: &AgenticEvent) -> Option<&str> { + match event { + AgenticEvent::DialogTurnStarted { turn_id, .. } + | AgenticEvent::DialogTurnCompleted { turn_id, .. } + | AgenticEvent::DialogTurnCancelled { turn_id, .. } + | AgenticEvent::DialogTurnFailed { turn_id, .. } + | AgenticEvent::TokenUsageUpdated { turn_id, .. } + | AgenticEvent::ContextCompressionStarted { turn_id, .. } + | AgenticEvent::ContextCompressionCompleted { turn_id, .. } + | AgenticEvent::ContextCompressionFailed { turn_id, .. } + | AgenticEvent::ModelRoundStarted { turn_id, .. } + | AgenticEvent::ModelRoundCompleted { turn_id, .. } + | AgenticEvent::TextChunk { turn_id, .. } + | AgenticEvent::ThinkingChunk { turn_id, .. } + | AgenticEvent::ToolEvent { turn_id, .. } + | AgenticEvent::DeepReviewQueueStateChanged { turn_id, .. } + | AgenticEvent::UserSteeringInjected { turn_id, .. } => Some(turn_id), + _ => None, + } +} + +fn terminal_outcome( + event: &AgenticEvent, + turn_id: &str, +) -> Option<(DispatchJobState, Option)> { + match event { + AgenticEvent::DialogTurnCompleted { + turn_id: event_turn, + success, + finish_reason, + has_final_response, + .. + } if event_turn == turn_id => { + if *success == Some(false) { + let reason = finish_reason + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("unsuccessful_completion"); + let detail = if *has_final_response == Some(false) { + format!("Dispatch completed without a successful final response: {reason}") + } else { + format!("Dispatch completed unsuccessfully: {reason}") + }; + Some((DispatchJobState::Failed, Some(detail))) + } else { + Some((DispatchJobState::Succeeded, None)) + } + } + AgenticEvent::DialogTurnFailed { + turn_id: event_turn, + error, + .. + } if event_turn == turn_id => Some((DispatchJobState::Failed, Some(error.clone()))), + AgenticEvent::DialogTurnCancelled { + turn_id: event_turn, + .. + } if event_turn == turn_id => Some(( + DispatchJobState::Cancelled, + Some("Dispatch turn was cancelled".to_string()), + )), + AgenticEvent::SystemError { error, .. } => { + Some((DispatchJobState::Failed, Some(error.clone()))) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_events_map_to_persistent_job_states() { + let completed = AgenticEvent::DialogTurnCompleted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + total_rounds: 1, + total_tools: 0, + duration_ms: 10, + partial_recovery_reason: None, + success: Some(true), + finish_reason: Some("stop".to_string()), + has_final_response: Some(true), + }; + assert_eq!( + terminal_outcome(&completed, "turn-1"), + Some((DispatchJobState::Succeeded, None)) + ); + + let failed = AgenticEvent::DialogTurnFailed { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + error: "model failed".to_string(), + error_category: None, + error_detail: None, + }; + assert_eq!( + terminal_outcome(&failed, "turn-1"), + Some((DispatchJobState::Failed, Some("model failed".to_string()))) + ); + } + + #[test] + fn dispatch_does_not_publish_subagent_sessions_before_child_observers_exist() { + let linked = AgenticEvent::SubagentSessionLinked { + session_id: "child-session".to_string(), + subagent_dialog_turn_id: "child-turn".to_string(), + parent_session_id: "session-1".to_string(), + parent_dialog_turn_id: "turn-1".to_string(), + parent_tool_call_id: "tool-1".to_string(), + agent_type: Some("GeneralPurpose".to_string()), + model_id: None, + focused_review_display_label: None, + }; + assert!(!event_belongs_to_job(&linked, "session-1", "turn-1")); + + let child_chunk = AgenticEvent::TextChunk { + session_id: "child-session".to_string(), + turn_id: "child-turn".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "child output".to_string(), + }; + assert!(!event_belongs_to_job(&child_chunk, "session-1", "turn-1")); + + let parent_chunk = AgenticEvent::TextChunk { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "parent output".to_string(), + }; + assert!(event_belongs_to_job(&parent_chunk, "session-1", "turn-1")); + } +} diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 6dae37650a..654d8312d2 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -18,6 +18,7 @@ mod chat_state; mod config; mod daemon; mod diagnostics; +mod dispatch; mod hook_import; mod logging; mod management; @@ -261,6 +262,12 @@ enum Commands { action: DaemonAction, }, + /// Run and inspect persistent tasks owned by this machine + Dispatch { + #[command(subcommand)] + action: DispatchAction, + }, + /// Start or inspect the Agent Client Protocol (ACP) server Acp { #[command(subcommand)] @@ -574,6 +581,25 @@ enum DaemonAction { Status, } +#[derive(Subcommand)] +pub(crate) enum DispatchAction { + /// Report target protocol and local execution readiness + Probe, + /// Persist and start a detached dispatch job + Submit, + /// Read job state and incremental events + Status, + /// Cancel a detached dispatch job + Cancel, + /// List jobs owned by this machine + List, + #[command(name = "__run", hide = true)] + Run { + #[arg(long)] + job: String, + }, +} + // ======================== System Initialization ======================== /// Return the current project path. CLI session scope is intentionally cwd-only. @@ -917,6 +943,10 @@ impl std::fmt::Display for ReportedCliError { impl std::error::Error for ReportedCliError {} +fn is_dispatch_command(command: &Option) -> bool { + matches!(command, Some(Commands::Dispatch { .. })) +} + async fn run_cli() -> Result<()> { let raw_args = std::env::args_os().collect::>(); let product_binary_name = option_env!("BITFUN_PRODUCT_BINARY_NAME").unwrap_or("bitfun"); @@ -961,6 +991,7 @@ async fn run_cli() -> Result<()> { Err(error) => return Err(error), }; let is_exec_mode = matches!(cli.command, Some(Commands::Exec { .. })); + let is_dispatch_mode = is_dispatch_command(&cli.command); let is_daemon_run = matches!( cli.command, Some(Commands::Daemon { @@ -978,7 +1009,7 @@ async fn run_cli() -> Result<()> { let service_log_dir = logging::resolve_logs_root().join(format!("shared-runtime-{}", std::process::id())); logging::init_file_logging_at(&service_log_dir, file_log_level); - } else if is_tui_mode || is_exec_mode || is_daemon_run { + } else if is_tui_mode || is_exec_mode || is_daemon_run || is_dispatch_mode { logging::init_file_logging(file_log_level); } else { tracing_subscriber::fmt() @@ -1177,6 +1208,10 @@ async fn run_cli() -> Result<()> { DaemonAction::Status => daemon::print_status()?, }, + Some(Commands::Dispatch { action }) => { + root_handlers::handle_dispatch_action(action).await?; + } + Some(Commands::Acp { action: None | Some(AcpAction::Serve), }) => { @@ -1737,3 +1772,43 @@ mod shared_tui_command_tests { assert!(!exec_help.contains("--shared")); } } + +#[cfg(test)] +mod dispatch_command_tests { + use super::{is_dispatch_command, Cli, Commands, DispatchAction}; + use clap::{CommandFactory, Parser}; + + #[test] + fn dispatch_commands_parse_and_internal_worker_is_hidden() { + let status = + Cli::try_parse_from(["bitfun", "dispatch", "status"]).expect("parse dispatch status"); + assert!(matches!( + status.command, + Some(Commands::Dispatch { + action: DispatchAction::Status + }) + )); + assert!(is_dispatch_command(&status.command)); + + let worker = Cli::try_parse_from(["bitfun", "dispatch", "__run", "--job", "job-1"]) + .expect("parse internal dispatch worker"); + assert!(matches!( + worker.command, + Some(Commands::Dispatch { + action: DispatchAction::Run { ref job } + }) if job == "job-1" + )); + assert!(is_dispatch_command(&worker.command)); + let unrelated = Cli::try_parse_from(["bitfun", "config", "show"]).expect("parse config"); + assert!(!is_dispatch_command(&unrelated.command)); + + let help = Cli::command().render_long_help().to_string(); + assert!(help.contains("dispatch")); + let dispatch_help = Cli::command() + .find_subcommand_mut("dispatch") + .expect("dispatch command") + .render_long_help() + .to_string(); + assert!(!dispatch_help.contains("__run")); + } +} diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 743ce4d1af..0061a78b4b 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -72,6 +72,15 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "relay_deploy_cancel", "relay_deploy_register", "relay_deploy_verify", + "dispatch_list_targets", + "dispatch_probe_target", + "dispatch_install_cli_start", + "dispatch_install_cli_poll", + "dispatch_install_cli_cancel", + "dispatch_submit", + "dispatch_status", + "dispatch_cancel", + "dispatch_list_jobs", ]; /// Desktop IDE surfaces that CLI Peer Host does not implement. @@ -109,3 +118,25 @@ pub(crate) fn is_cli_unsupported_command(command: &str) -> bool { ]; prefixes.iter().any(|prefix| command.starts_with(prefix)) } + +#[cfg(test)] +mod tests { + use super::is_local_only_command; + + #[test] + fn outbound_dispatch_control_plane_stays_local_only() { + for command in [ + "dispatch_list_targets", + "dispatch_probe_target", + "dispatch_install_cli_start", + "dispatch_install_cli_poll", + "dispatch_install_cli_cancel", + "dispatch_submit", + "dispatch_status", + "dispatch_cancel", + "dispatch_list_jobs", + ] { + assert!(is_local_only_command(command), "{command}"); + } + } +} diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index d5e01b520c..92f9e295aa 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -23,7 +23,7 @@ use crate::{ emit_preflight_json_error, ExecApprovalMode, ExecMode, ExecOutputFormat, ExecSessionOptions, }, ui::string_utils::truncate_str, - ConfigAction, ExternalAccessArg, ExternalCapabilityArg, ExternalConfigAction, + ConfigAction, DispatchAction, ExternalAccessArg, ExternalCapabilityArg, ExternalConfigAction, ExternalPolicyModeArg, ExternalPolicyScopeArg, SessionAction, }; @@ -41,6 +41,49 @@ pub(crate) struct ExecCommandArgs { pub approval_mode: ExecApprovalMode, } +pub(crate) async fn handle_dispatch_action(action: DispatchAction) -> Result<()> { + let verb = match action { + DispatchAction::Run { job } => return crate::dispatch::run_worker(job).await, + DispatchAction::Probe => "probe", + DispatchAction::Submit => "submit", + DispatchAction::Status => "status", + DispatchAction::Cancel => "cancel", + DispatchAction::List => "list", + }; + let result = async { + use std::io::{IsTerminal, Read}; + let mut raw = String::new(); + let mut stdin = std::io::stdin(); + if !stdin.is_terminal() { + stdin + .read_to_string(&mut raw) + .context("read dispatch JSON from stdin")?; + } + let input = if raw.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&raw).context("decode dispatch JSON from stdin")? + }; + crate::dispatch::run_dispatch_verb(verb, input).await + } + .await; + + match result { + Ok(response) => { + println!("{}", serde_json::to_string(&response)?); + Ok(()) + } + Err(error) => { + let response = serde_json::json!({ + "protocolVersion": crate::dispatch::protocol::DISPATCH_PROTOCOL_VERSION, + "error": format!("{error:#}"), + }); + println!("{}", serde_json::to_string(&response)?); + Err(error) + } + } +} + pub(crate) async fn handle_exec_command(config: CliConfig, args: ExecCommandArgs) -> Result<()> { let workspace_path_resolved = std::env::current_dir().ok(); diff --git a/src/apps/cli/src/runtime/approval.rs b/src/apps/cli/src/runtime/approval.rs index c0901036da..df6170f798 100644 --- a/src/apps/cli/src/runtime/approval.rs +++ b/src/apps/cli/src/runtime/approval.rs @@ -1,4 +1,6 @@ -use bitfun_agent_runtime::sdk::PermissionRequest; +use bitfun_agent_runtime::sdk::{PermissionRequest, AUTO_APPROVE_ASK_CONTEXT_KEY}; +use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; +use serde_json::{Map, Value}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CliApprovalPolicy { @@ -10,6 +12,35 @@ pub(crate) enum CliApprovalPolicy { Auto, } +/// Build invocation-scoped approval metadata consumed by the shared Runtime. +/// +/// Headless entrypoints must use this helper instead of mutating persisted +/// confirmation settings or defining a parallel permission mechanism. +pub(crate) fn approval_metadata(approval_policy: CliApprovalPolicy) -> Map { + let mut metadata = Map::new(); + if matches!( + approval_policy, + CliApprovalPolicy::Reject | CliApprovalPolicy::Auto + ) { + metadata.insert( + USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), + Value::Bool(false), + ); + } + let auto_approve_ask = match approval_policy { + CliApprovalPolicy::Ask => None, + CliApprovalPolicy::DisableAuto | CliApprovalPolicy::Reject => Some(false), + CliApprovalPolicy::Auto => Some(true), + }; + if let Some(auto_approve_ask) = auto_approve_ask { + metadata.insert( + AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), + Value::Bool(auto_approve_ask), + ); + } + metadata +} + pub(crate) fn permission_request_targets_session( request: &PermissionRequest, session_id: &str, @@ -23,11 +54,12 @@ pub(crate) fn permission_request_targets_session( #[cfg(test)] mod tests { - use super::permission_request_targets_session; + use super::{approval_metadata, permission_request_targets_session, CliApprovalPolicy}; use bitfun_agent_runtime::sdk::{ PermissionDelegationContext, PermissionRequest, PermissionRequestSource, - PermissionRequestSourceKind, + PermissionRequestSourceKind, AUTO_APPROVE_ASK_CONTEXT_KEY, }; + use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; use serde_json::Map; fn request() -> PermissionRequest { @@ -74,4 +106,33 @@ mod tests { "unrelated-session" )); } + + #[test] + fn headless_approval_metadata_is_invocation_scoped() { + let auto = approval_metadata(CliApprovalPolicy::Auto); + assert_eq!( + auto.get(USER_INPUT_AVAILABLE_CONTEXT_KEY), + Some(&serde_json::Value::Bool(false)) + ); + assert_eq!( + auto.get(AUTO_APPROVE_ASK_CONTEXT_KEY), + Some(&serde_json::Value::Bool(true)) + ); + + let reject = approval_metadata(CliApprovalPolicy::Reject); + assert_eq!( + reject.get(USER_INPUT_AVAILABLE_CONTEXT_KEY), + Some(&serde_json::Value::Bool(false)) + ); + assert_eq!( + reject.get(AUTO_APPROVE_ASK_CONTEXT_KEY), + Some(&serde_json::Value::Bool(false)) + ); + + assert!(approval_metadata(CliApprovalPolicy::Ask).is_empty()); + assert_eq!( + approval_metadata(CliApprovalPolicy::DisableAuto).get(AUTO_APPROVE_ASK_CONTEXT_KEY), + Some(&serde_json::Value::Bool(false)) + ); + } } diff --git a/src/apps/desktop/src/api/dispatch_api.rs b/src/apps/desktop/src/api/dispatch_api.rs new file mode 100644 index 0000000000..710edce099 --- /dev/null +++ b/src/apps/desktop/src/api/dispatch_api.rs @@ -0,0 +1,158 @@ +//! SSH task dispatch Tauri adapter. +//! +//! The remote CLI owns the authoritative job and session. These commands are +//! thin host adapters around the platform-neutral dispatch controller and its +//! observer-only outbound index. + +use std::sync::Arc; + +use bitfun_core::infrastructure::PathManager; +use bitfun_core::service::dispatch::{ + cancel_dispatch, cancel_dispatch_cli_install, get_dispatch_status, list_dispatch_jobs, + list_dispatch_targets, poll_dispatch_cli_install, probe_dispatch_target, + start_dispatch_cli_install, submit_dispatch, DispatchConnectionRequest, + DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, + DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, + DispatchStatusRequest, DispatchSubmitRequest, DispatchTargetOption, OutboundDispatchStore, +}; +use bitfun_core::service::remote_ssh::dispatch_ssh::{ + DispatchInstallPoll, DispatchInstallStart, DispatchSshProbe, +}; +use serde_json::Value; +use tauri::State; + +use super::app_state::AppState; + +#[tauri::command] +pub async fn dispatch_list_targets( + state: State<'_, AppState>, + request: DispatchListTargetsRequest, +) -> Result, String> { + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + list_dispatch_targets(&manager, request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn dispatch_probe_target( + state: State<'_, AppState>, + request: DispatchProbeTargetRequest, +) -> Result { + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + probe_dispatch_target(&manager, request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn dispatch_install_cli_start( + state: State<'_, AppState>, + request: DispatchInstallStartRequest, +) -> Result { + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + start_dispatch_cli_install(&manager, request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn dispatch_install_cli_poll( + state: State<'_, AppState>, + request: DispatchInstallPollRequest, +) -> Result { + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + poll_dispatch_cli_install(&manager, request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn dispatch_install_cli_cancel( + state: State<'_, AppState>, + request: DispatchConnectionRequest, +) -> Result<(), String> { + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + cancel_dispatch_cli_install(&manager, request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn dispatch_submit( + state: State<'_, AppState>, + path_manager: State<'_, Arc>, + request: DispatchSubmitRequest, +) -> Result { + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + let store = OutboundDispatchStore::new(path_manager.as_ref()); + submit_dispatch(&manager, &store, request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn dispatch_status( + state: State<'_, AppState>, + path_manager: State<'_, Arc>, + request: DispatchStatusRequest, +) -> Result { + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + let store = OutboundDispatchStore::new(path_manager.as_ref()); + get_dispatch_status(&manager, &store, request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn dispatch_cancel( + state: State<'_, AppState>, + path_manager: State<'_, Arc>, + request: DispatchJobRequest, +) -> Result { + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + let store = OutboundDispatchStore::new(path_manager.as_ref()); + cancel_dispatch(&manager, &store, request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn dispatch_list_jobs( + state: State<'_, AppState>, + path_manager: State<'_, Arc>, + request: DispatchListJobsRequest, +) -> Result { + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + let store = OutboundDispatchStore::new(path_manager.as_ref()); + list_dispatch_jobs(&manager, &store, request) + .await + .map_err(|error| error.to_string()) +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 0d4bc3538b..58e3f61ca0 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -17,6 +17,7 @@ pub mod cron_api; pub mod custom_agent_api; pub mod debug_api; pub mod diff_api; +pub mod dispatch_api; pub mod dto; pub mod editor_ai_api; pub mod external_hooks_api; diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index e7a6865dfb..28738b7dbd 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -94,6 +94,16 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ // This-machine computer-use / OS permission prompts "computer_use_request_permissions", "computer_use_open_system_settings", + // Detached dispatch uses controller-owned SSH credentials and observers. + "dispatch_list_targets", + "dispatch_probe_target", + "dispatch_install_cli_start", + "dispatch_install_cli_poll", + "dispatch_install_cli_cancel", + "dispatch_submit", + "dispatch_status", + "dispatch_cancel", + "dispatch_list_jobs", // One-click relay deploy SSHes from the controller to a user host "relay_deploy_preflight", "relay_deploy_install_docker", diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index d6250a6823..ecd9327fea 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -338,6 +338,35 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ("delete_session", RemoteWorkspacePolicy::LegacyUnaudited), ("delete_skill", RemoteWorkspacePolicy::LegacyUnaudited), ("delete_subagent", RemoteWorkspacePolicy::LegacyUnaudited), + // Detached dispatch is routed by its own immutable target and observer + // index, never by the currently open workspace. + ("dispatch_cancel", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "dispatch_install_cli_cancel", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "dispatch_install_cli_poll", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "dispatch_install_cli_start", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "dispatch_list_jobs", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "dispatch_list_targets", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "dispatch_probe_target", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("dispatch_status", RemoteWorkspacePolicy::WorkspaceAgnostic), + ("dispatch_submit", RemoteWorkspacePolicy::WorkspaceAgnostic), ( "dismiss_announcement", RemoteWorkspacePolicy::WorkspaceAgnostic, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 754afdd041..508d7c505d 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1725,6 +1725,16 @@ pub async fn run() { api::ssh_api::remote_close_workspace, api::ssh_api::remote_remove_workspace, api::ssh_api::remote_get_workspace_info, + // Detached task dispatch (controller-side SSH transport) + api::dispatch_api::dispatch_list_targets, + api::dispatch_api::dispatch_probe_target, + api::dispatch_api::dispatch_install_cli_start, + api::dispatch_api::dispatch_install_cli_poll, + api::dispatch_api::dispatch_install_cli_cancel, + api::dispatch_api::dispatch_submit, + api::dispatch_api::dispatch_status, + api::dispatch_api::dispatch_cancel, + api::dispatch_api::dispatch_list_jobs, // Relay self-deploy API api::relay_deploy_api::relay_deploy_preflight, api::relay_deploy_api::relay_deploy_install_docker, diff --git a/src/apps/server/Cargo.toml b/src/apps/server/Cargo.toml index e8a83ad459..f2db9afe74 100644 --- a/src/apps/server/Cargo.toml +++ b/src/apps/server/Cargo.toml @@ -27,7 +27,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } futures-util = { workspace = true } chrono = { workspace = true } +dirs = { workspace = true } [lints] workspace = true - diff --git a/src/apps/server/src/main.rs b/src/apps/server/src/main.rs index 037fdc6199..e5d3504933 100644 --- a/src/apps/server/src/main.rs +++ b/src/apps/server/src/main.rs @@ -17,11 +17,17 @@ use tower_http::cors::CorsLayer; mod routes; +pub(crate) struct DispatchHostState { + path_manager: Arc, + ssh_manager: Arc, +} + /// Application state #[derive(Clone)] pub struct AppState { external_workspace_root: Option, allowed_browser_origins: Arc>, + dispatch_host: Option>, } const DEFAULT_ALLOWED_BROWSER_ORIGINS: [&str; 2] = @@ -95,9 +101,32 @@ async fn main() -> Result<()> { .map_err(|_| anyhow::anyhow!("--allowed-origin contains an invalid header value")) }) .collect::>>()?; + + // This is a narrow controller/observer capability. It deliberately does + // not initialize the Server Host's dormant Agent Runtime: authoritative + // sessions and execution stay inside the target-side `bitfun dispatch` + // worker. + let path_manager = Arc::new(bitfun_core::infrastructure::PathManager::new()?); + let ssh_data_dir = dirs::data_local_dir() + .ok_or_else(|| anyhow::anyhow!("Could not resolve the local data directory"))? + .join("BitFun") + .join("ssh"); + let ssh_manager = Arc::new(bitfun_core::service::remote_ssh::SSHConnectionManager::new( + ssh_data_dir, + )); + if let Err(error) = ssh_manager.load_saved_connections().await { + tracing::warn!(error = %error, "Failed to load saved SSH connections"); + } + if let Err(error) = ssh_manager.load_known_hosts().await { + tracing::warn!(error = %error, "Failed to load SSH known hosts"); + } let app_state = AppState { external_workspace_root, allowed_browser_origins: Arc::new(allowed_browser_origins), + dispatch_host: Some(Arc::new(DispatchHostState { + path_manager, + ssh_manager, + })), }; let app = Router::new() @@ -205,5 +234,9 @@ mod tests { !main_source.contains("bootstrap::initialize"), "the current read-only HTTP shell must not silently start an Agent Runtime" ); + assert!( + main_source.contains("DispatchHostState"), + "the lightweight Server Host should expose dispatch without booting an Agent Runtime" + ); } } diff --git a/src/apps/server/src/routes/dispatch.rs b/src/apps/server/src/routes/dispatch.rs new file mode 100644 index 0000000000..14f96e96f9 --- /dev/null +++ b/src/apps/server/src/routes/dispatch.rs @@ -0,0 +1,183 @@ +//! Narrow detached-dispatch capability for the lightweight Server Host. +//! +//! This route owns no Agent Runtime and no target session. It only exposes the +//! same platform-neutral controller used by Desktop, backed by saved SSH +//! profiles and the observer-only outbound index. + +use bitfun_core::external_sources::{ + ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourceOperationResult, +}; +use bitfun_core::service::dispatch::{ + cancel_dispatch, cancel_dispatch_cli_install, get_dispatch_status, list_dispatch_jobs, + list_dispatch_targets, poll_dispatch_cli_install, probe_dispatch_target, + start_dispatch_cli_install, submit_dispatch, DispatchConnectionRequest, + DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, + DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, + DispatchStatusRequest, DispatchSubmitRequest, OutboundDispatchStore, +}; +use serde::de::DeserializeOwned; + +use crate::{AppState, DispatchHostState}; + +pub(crate) fn supports(method: &str) -> bool { + matches!( + method, + "dispatch_list_targets" + | "dispatch_probe_target" + | "dispatch_install_cli_start" + | "dispatch_install_cli_poll" + | "dispatch_install_cli_cancel" + | "dispatch_submit" + | "dispatch_status" + | "dispatch_cancel" + | "dispatch_list_jobs" + ) +} + +pub(crate) async fn dispatch( + method: &str, + params: serde_json::Value, + state: &AppState, +) -> ExternalSourceOperationResult { + let host = state.dispatch_host.as_deref().ok_or_else(|| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::HostUnavailable, + "Detached dispatch is not initialized on this Server Host", + false, + ) + })?; + + match method { + "dispatch_list_targets" => { + let request = parse_request::(¶ms)?; + encode( + list_dispatch_targets(&host.ssh_manager, request) + .await + .map_err(operation_error)?, + ) + } + "dispatch_probe_target" => { + let request = parse_request::(¶ms)?; + encode( + probe_dispatch_target(&host.ssh_manager, request) + .await + .map_err(operation_error)?, + ) + } + "dispatch_install_cli_start" => { + let request = parse_request::(¶ms)?; + encode( + start_dispatch_cli_install(&host.ssh_manager, request) + .await + .map_err(operation_error)?, + ) + } + "dispatch_install_cli_poll" => { + let request = parse_request::(¶ms)?; + encode( + poll_dispatch_cli_install(&host.ssh_manager, request) + .await + .map_err(operation_error)?, + ) + } + "dispatch_install_cli_cancel" => { + let request = parse_request::(¶ms)?; + cancel_dispatch_cli_install(&host.ssh_manager, request) + .await + .map_err(operation_error)?; + Ok(serde_json::Value::Null) + } + "dispatch_submit" => { + let request = parse_request::(¶ms)?; + submit_dispatch(&host.ssh_manager, &store(host), request) + .await + .map_err(operation_error) + } + "dispatch_status" => { + let request = parse_request::(¶ms)?; + get_dispatch_status(&host.ssh_manager, &store(host), request) + .await + .map_err(operation_error) + } + "dispatch_cancel" => { + let request = parse_request::(¶ms)?; + cancel_dispatch(&host.ssh_manager, &store(host), request) + .await + .map_err(operation_error) + } + "dispatch_list_jobs" => { + let request = parse_request::(¶ms)?; + list_dispatch_jobs(&host.ssh_manager, &store(host), request) + .await + .map_err(operation_error) + } + _ => Err(ExternalSourceOperationError::host_capability_unavailable( + "Unknown detached dispatch operation", + )), + } +} + +fn store(host: &DispatchHostState) -> OutboundDispatchStore { + OutboundDispatchStore::new(&host.path_manager) +} + +fn parse_request( + params: &serde_json::Value, +) -> ExternalSourceOperationResult { + let request = params.get("request").ok_or_else(|| { + ExternalSourceOperationError::invalid_request("missing structured request") + })?; + serde_json::from_value(request.clone()).map_err(|error| { + ExternalSourceOperationError::invalid_request(format!( + "invalid detached dispatch request: {error}" + )) + }) +} + +fn encode(value: impl serde::Serialize) -> ExternalSourceOperationResult { + serde_json::to_value(value).map_err(|_| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::Internal, + "Detached dispatch response could not be encoded", + false, + ) + }) +} + +fn operation_error(error: anyhow::Error) -> ExternalSourceOperationError { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::DependencyFailed, + error.to_string(), + true, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claims_only_the_narrow_dispatch_contract() { + for method in [ + "dispatch_list_targets", + "dispatch_probe_target", + "dispatch_install_cli_start", + "dispatch_install_cli_poll", + "dispatch_install_cli_cancel", + "dispatch_submit", + "dispatch_status", + "dispatch_cancel", + "dispatch_list_jobs", + ] { + assert!(supports(method), "{method}"); + } + assert!(!supports("start_dialog_turn")); + assert!(!supports("account_execute_on_device")); + } + + #[test] + fn structured_requests_are_required() { + let error = parse_request::(&serde_json::json!({})).unwrap_err(); + assert_eq!(error.code, ExternalSourceOperationErrorCode::InvalidRequest); + } +} diff --git a/src/apps/server/src/routes/external_sources.rs b/src/apps/server/src/routes/external_sources.rs index 07c7d84994..603aad5496 100644 --- a/src/apps/server/src/routes/external_sources.rs +++ b/src/apps/server/src/routes/external_sources.rs @@ -140,6 +140,7 @@ mod tests { AppState { external_workspace_root, allowed_browser_origins: Default::default(), + dispatch_host: None, } } diff --git a/src/apps/server/src/routes/mod.rs b/src/apps/server/src/routes/mod.rs index 3e05d76d11..0a6d320f46 100644 --- a/src/apps/server/src/routes/mod.rs +++ b/src/apps/server/src/routes/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod api; +pub(crate) mod dispatch; pub(crate) mod external_sources; /// Routes module /// diff --git a/src/apps/server/src/routes/websocket.rs b/src/apps/server/src/routes/websocket.rs index 33d1f58b14..3c6cfa9c93 100644 --- a/src/apps/server/src/routes/websocket.rs +++ b/src/apps/server/src/routes/websocket.rs @@ -210,6 +210,9 @@ async fn handle_command( if super::external_sources::supports(method) { return super::external_sources::dispatch(method, params, state).await; } + if super::dispatch::supports(method) { + return super::dispatch::dispatch(method, params, state).await; + } match method { "ping" => Ok(serde_json::json!({ "pong": true, @@ -279,6 +282,7 @@ mod tests { allowed_browser_origins: std::sync::Arc::new( origins.iter().map(|origin| (*origin).to_string()).collect(), ), + dispatch_host: None, } } diff --git a/src/crates/assembly/core/src/runtime_ownership_tests.rs b/src/crates/assembly/core/src/runtime_ownership_tests.rs index eba6d90846..b50a47eb4a 100644 --- a/src/crates/assembly/core/src/runtime_ownership_tests.rs +++ b/src/crates/assembly/core/src/runtime_ownership_tests.rs @@ -6,6 +6,7 @@ use bitfun_services_core::runtime_ownership::{ use tempfile::tempdir; use crate::runtime_ownership::CoreRuntimeOwnership; +use crate::service::dispatch::{DispatchTarget, OutboundDispatchRecord, OutboundDispatchStore}; #[test] fn embedded_owner_is_idempotent_and_keeps_one_workspace_lease() { @@ -166,6 +167,52 @@ fn ssh_host_without_connection_id_cannot_bypass_local_ownership() { drop(shared); } +#[tokio::test] +async fn dispatch_observer_record_never_acquires_local_workspace_ownership() { + let ownership_root = tempdir().expect("ownership root"); + let workspace = tempdir().expect("workspace"); + let outbound_root = tempdir().expect("outbound root"); + let shared = CoreRuntimeOwnership::shared_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "shared-test", + workspace.path(), + ) + .expect("shared owner"); + let ownership_entries_before = std::fs::read_dir(ownership_root.path()) + .expect("read ownership root") + .count(); + + let store = OutboundDispatchStore::new_in_root_for_tests(outbound_root.path().join("outbound")); + let workspace_path = workspace.path().to_string_lossy().into_owned(); + let record = OutboundDispatchRecord::new( + "job-remote".to_string(), + DispatchTarget::Ssh { + connection_id: "server-a".to_string(), + workspace_path: workspace_path.clone(), + display_name: "Server A".to_string(), + }, + "session-remote".to_string(), + workspace_path, + "Run only on the target", + "queued", + ) + .expect("record"); + store + .bind_if_absent(&record) + .await + .expect("observer index must not contend for runtime ownership"); + + assert_eq!( + std::fs::read_dir(ownership_root.path()) + .expect("read ownership root") + .count(), + ownership_entries_before, + "observer persistence must not create a local workspace lease" + ); + drop(shared); +} + #[test] fn startup_errors_expose_codes_without_mislabeling_path_failures_as_conflicts() { let ownership_root = tempdir().expect("ownership root"); diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs new file mode 100644 index 0000000000..0291b897a3 --- /dev/null +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -0,0 +1,505 @@ +use bitfun_services_integrations::remote_ssh::{ + dispatch_ssh::{ + self, DispatchCliRelease, DispatchInstallPoll, DispatchInstallStart, DispatchSshProbe, + }, + SSHConnectionManager, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use super::{DispatchTarget, DispatchTargetRequest, OutboundDispatchRecord, OutboundDispatchStore}; + +const DISPATCH_PROTOCOL_VERSION: u64 = 1; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DispatchListTargetsRequest {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchProbeTargetRequest { + pub target: DispatchTargetRequest, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchConnectionRequest { + pub connection_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchInstallStartRequest { + pub connection_id: String, + pub release: DispatchCliRelease, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchInstallPollRequest { + pub connection_id: String, + #[serde(default)] + pub cursor: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchSubmitRequest { + pub target: DispatchTargetRequest, + pub job_id: String, + pub session_id: String, + pub agent_type: String, + pub prompt: String, + pub approval_policy: String, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub title: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchStatusRequest { + pub job_id: String, + #[serde(default)] + pub cursor: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchJobRequest { + pub job_id: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchListJobsRequest { + #[serde(default)] + pub target: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchTargetOption { + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub connection_id: Option, + pub display_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_workspace: Option, +} + +pub async fn list_targets( + manager: &SSHConnectionManager, + _request: DispatchListTargetsRequest, +) -> anyhow::Result> { + let mut targets = vec![DispatchTargetOption { + kind: "local".to_string(), + connection_id: None, + display_name: "Local".to_string(), + description: None, + default_workspace: None, + }]; + targets.extend( + manager + .get_saved_connections() + .await + .into_iter() + .map(|connection| DispatchTargetOption { + kind: "ssh".to_string(), + connection_id: Some(connection.id), + display_name: connection.name, + description: Some(format!( + "{}@{}:{}", + connection.username, connection.host, connection.port + )), + default_workspace: connection.default_workspace, + }), + ); + Ok(targets) +} + +pub async fn probe_target( + manager: &SSHConnectionManager, + request: DispatchProbeTargetRequest, +) -> anyhow::Result { + let DispatchTargetRequest::Ssh { + connection_id, + workspace_path, + } = request.target + else { + anyhow::bail!("Phase-one dispatch probing supports SSH targets only"); + }; + dispatch_ssh::probe(manager, &connection_id, nonempty(&workspace_path)).await +} + +pub async fn install_cli_start( + manager: &SSHConnectionManager, + request: DispatchInstallStartRequest, +) -> anyhow::Result { + dispatch_ssh::install_cli_start(manager, request.connection_id.trim(), &request.release).await +} + +pub async fn install_cli_poll( + manager: &SSHConnectionManager, + request: DispatchInstallPollRequest, +) -> anyhow::Result { + dispatch_ssh::install_cli_poll(manager, request.connection_id.trim(), request.cursor).await +} + +pub async fn install_cli_cancel( + manager: &SSHConnectionManager, + request: DispatchConnectionRequest, +) -> anyhow::Result<()> { + dispatch_ssh::install_cli_cancel(manager, request.connection_id.trim()).await +} + +pub async fn submit( + manager: &SSHConnectionManager, + store: &OutboundDispatchStore, + request: DispatchSubmitRequest, +) -> anyhow::Result { + if !matches!( + request.approval_policy.as_str(), + "auto" | "reject-and-report" + ) { + anyhow::bail!( + "Dispatch approvalPolicy must be explicitly set to auto or reject-and-report" + ); + } + if request.prompt.trim().is_empty() { + anyhow::bail!("Dispatch prompt cannot be empty"); + } + + let DispatchTargetRequest::Ssh { + connection_id, + workspace_path, + } = &request.target + else { + anyhow::bail!("Phase-one dispatch submission supports SSH targets only"); + }; + if connection_id.trim().is_empty() || workspace_path.trim().is_empty() { + anyhow::bail!("SSH dispatch requires a connectionId and workspacePath"); + } + + // Re-check the executable that will receive this submission. The picker + // probe can be stale, and headless callers can bypass the UI entirely. + let preflight = + dispatch_ssh::probe(manager, connection_id, Some(workspace_path.trim())).await?; + let protocol = preflight.protocol.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "{}", + preflight + .protocol_error + .as_deref() + .or(preflight.install_error.as_deref()) + .unwrap_or("BitFun CLI dispatch protocol is unavailable on the SSH target") + ) + })?; + dispatch_ssh::validate_dispatch_protocol(protocol, Some(&request.approval_policy))?; + validate_submission_preflight(protocol, request.model.as_deref())?; + + let display_name = manager + .get_saved_connections() + .await + .into_iter() + .find(|connection| connection.id == *connection_id) + .map(|connection| connection.name) + .unwrap_or_else(|| connection_id.clone()); + let resolved_target = DispatchTarget::Ssh { + connection_id: connection_id.clone(), + workspace_path: workspace_path.clone(), + display_name, + }; + + let requested_record = OutboundDispatchRecord::new( + request.job_id.clone(), + resolved_target, + request.session_id.clone(), + workspace_path.clone(), + &request.prompt, + "submitting", + )?; + let bound_record = store.bind_if_absent(&requested_record).await?; + if bound_record.session_id != request.session_id + || !same_target_identity(&bound_record.target, &requested_record.target) + { + anyhow::bail!("Dispatch jobId is already bound to another target or session"); + } + + let mut protocol_request = json!({ + "protocolVersion": DISPATCH_PROTOCOL_VERSION, + "jobId": request.job_id.clone(), + "sessionId": request.session_id.clone(), + "workspacePath": workspace_path, + "agentType": request.agent_type, + "prompt": request.prompt, + "approvalPolicy": request.approval_policy, + }); + if let Some(model) = request.model.filter(|value| !value.trim().is_empty()) { + protocol_request["model"] = Value::String(model); + } + if let Some(title) = request.title.filter(|value| !value.trim().is_empty()) { + protocol_request["title"] = Value::String(title); + } + + let response = match dispatch_ssh::submit(manager, connection_id, &protocol_request).await { + Ok(response) => response, + Err(error) => { + // The SSH response can be lost after the target has durably + // accepted and detached the worker. Preserve an observable, + // retryable state instead of freezing the outbound record at a + // false terminal failure; status or an idempotent re-submit will + // reconcile the authoritative target state. + let _ = store + .update_progress(&request.job_id, 0, "submission_unknown") + .await; + return Err(error); + } + }; + if let Err(error) = validate_submit_ack(&response, &request.job_id, &request.session_id) { + let _ = store + .update_progress(&request.job_id, 0, "submission_unknown") + .await; + return Err(error); + } + let state = response + .get("state") + .and_then(Value::as_str) + .unwrap_or("queued") + .to_string(); + store.update_progress(&request.job_id, 0, state).await?; + Ok(response) +} + +pub async fn status( + manager: &SSHConnectionManager, + store: &OutboundDispatchStore, + request: DispatchStatusRequest, +) -> anyhow::Result { + let record = store + .get(&request.job_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Outbound dispatch job was not found"))?; + let DispatchTarget::Ssh { connection_id, .. } = &record.target else { + anyhow::bail!("Phase-one dispatch status supports SSH targets only"); + }; + let response = dispatch_ssh::status( + manager, + connection_id, + &json!({ "jobId": request.job_id, "cursor": request.cursor }), + ) + .await?; + + // The request cursor is the last cursor the observer already applied. The + // response cursor is deliberately not persisted until the next poll, so a + // controller crash cannot skip events. + let state = response + .get("state") + .and_then(Value::as_str) + .unwrap_or(record.last_state.as_str()) + .to_string(); + store + .update_progress(&record.job_id, request.cursor, state) + .await?; + Ok(response) +} + +pub async fn cancel( + manager: &SSHConnectionManager, + store: &OutboundDispatchStore, + request: DispatchJobRequest, +) -> anyhow::Result { + let record = store + .get(&request.job_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Outbound dispatch job was not found"))?; + let DispatchTarget::Ssh { connection_id, .. } = &record.target else { + anyhow::bail!("Phase-one dispatch cancellation supports SSH targets only"); + }; + let response = + dispatch_ssh::cancel(manager, connection_id, &json!({ "jobId": request.job_id })).await?; + if response + .get("cancelled") + .and_then(Value::as_bool) + .unwrap_or(false) + { + store + .update_progress(&record.job_id, record.last_cursor, "cancelled") + .await?; + } + Ok(response) +} + +pub async fn list_jobs( + manager: &SSHConnectionManager, + store: &OutboundDispatchStore, + request: DispatchListJobsRequest, +) -> anyhow::Result { + let Some(target) = request.target else { + return Ok(serde_json::to_value(store.list().await?)?); + }; + let DispatchTargetRequest::Ssh { connection_id, .. } = target else { + anyhow::bail!("Phase-one dispatch listing supports SSH targets only"); + }; + dispatch_ssh::list(manager, &connection_id, &json!({})).await +} + +fn validate_submit_ack(response: &Value, job_id: &str, session_id: &str) -> anyhow::Result<()> { + if response.get("accepted").and_then(Value::as_bool) != Some(true) { + anyhow::bail!("Dispatch target did not accept the job"); + } + if response.get("jobId").and_then(Value::as_str) != Some(job_id) + || response.get("sessionId").and_then(Value::as_str) != Some(session_id) + { + anyhow::bail!("Dispatch target returned a mismatched acknowledgement"); + } + Ok(()) +} + +fn same_target_identity(left: &DispatchTarget, right: &DispatchTarget) -> bool { + match (left, right) { + (DispatchTarget::Local, DispatchTarget::Local) => true, + ( + DispatchTarget::Ssh { + connection_id: left_connection, + workspace_path: left_workspace, + .. + }, + DispatchTarget::Ssh { + connection_id: right_connection, + workspace_path: right_workspace, + .. + }, + ) => left_connection == right_connection && left_workspace == right_workspace, + ( + DispatchTarget::Device { + device_id: left_device, + workspace_path: left_workspace, + .. + }, + DispatchTarget::Device { + device_id: right_device, + workspace_path: right_workspace, + .. + }, + ) => left_device == right_device && left_workspace == right_workspace, + _ => false, + } +} + +fn validate_submission_preflight( + protocol: &Value, + requested_model: Option<&str>, +) -> anyhow::Result<()> { + let workspace = protocol + .get("workspace") + .and_then(Value::as_object) + .ok_or_else(|| anyhow::anyhow!("Dispatch target did not report workspace readiness"))?; + if workspace.get("exists").and_then(Value::as_bool) != Some(true) + || workspace.get("isDirectory").and_then(Value::as_bool) != Some(true) + { + anyhow::bail!("Dispatch workspace does not exist or is not a directory on the target"); + } + if let Some(requested_model) = requested_model + .map(str::trim) + .filter(|model| !model.is_empty()) + { + let available = protocol + .get("availableModels") + .and_then(Value::as_array) + .is_some_and(|models| { + models + .iter() + .any(|model| model.as_str() == Some(requested_model)) + }); + if !available { + anyhow::bail!( + "Requested model '{requested_model}' is not ready on the dispatch target" + ); + } + } else if protocol.get("modelConfigured").and_then(Value::as_bool) != Some(true) { + let diagnostic = protocol + .get("modelDiagnostic") + .and_then(Value::as_str) + .unwrap_or("No ready default model is configured on the dispatch target"); + anyhow::bail!("{diagnostic}"); + } + Ok(()) +} + +fn nonempty(value: &str) -> Option<&str> { + let value = value.trim(); + (!value.is_empty()).then_some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_false_or_mismatched_submit_acknowledgements() { + assert!(validate_submit_ack( + &json!({"accepted": false, "jobId": "j", "sessionId": "s"}), + "j", + "s" + ) + .is_err()); + assert!(validate_submit_ack( + &json!({"accepted": true, "jobId": "other", "sessionId": "s"}), + "j", + "s" + ) + .is_err()); + assert!(validate_submit_ack( + &json!({"accepted": true, "jobId": "j", "sessionId": "s"}), + "j", + "s" + ) + .is_ok()); + } + + #[test] + fn submission_preflight_requires_workspace_and_target_model_readiness() { + let ready = json!({ + "workspace": { "exists": true, "isDirectory": true }, + "modelConfigured": true, + "availableModels": ["target-model"] + }); + validate_submission_preflight(&ready, None).expect("target default"); + validate_submission_preflight(&ready, Some("target-model")).expect("selected model"); + assert!(validate_submission_preflight(&ready, Some("local-only-model")).is_err()); + + let missing_workspace = json!({ + "workspace": { "exists": false, "isDirectory": false }, + "modelConfigured": true, + "availableModels": [] + }); + assert!(validate_submission_preflight(&missing_workspace, None).is_err()); + + let missing_model = json!({ + "workspace": { "exists": true, "isDirectory": true }, + "modelConfigured": false, + "modelDiagnostic": "configure a model", + "availableModels": [] + }); + assert!(validate_submission_preflight(&missing_model, None).is_err()); + } + + #[test] + fn target_identity_ignores_mutable_display_names() { + let before = DispatchTarget::Ssh { + connection_id: "server-a".to_string(), + workspace_path: "/srv/app".to_string(), + display_name: "Old label".to_string(), + }; + let renamed = DispatchTarget::Ssh { + connection_id: "server-a".to_string(), + workspace_path: "/srv/app".to_string(), + display_name: "New label".to_string(), + }; + assert!(same_target_identity(&before, &renamed)); + } +} diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs new file mode 100644 index 0000000000..7b15356865 --- /dev/null +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -0,0 +1,409 @@ +#[cfg(feature = "ssh-remote")] +mod controller; +mod target; + +use std::path::{Path, PathBuf}; + +use bitfun_services_core::json_store::{JsonFileStore, JsonFileStoreError}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio::fs; + +use crate::infrastructure::PathManager; + +#[cfg(feature = "ssh-remote")] +pub use controller::{ + cancel as cancel_dispatch, install_cli_cancel as cancel_dispatch_cli_install, + install_cli_poll as poll_dispatch_cli_install, install_cli_start as start_dispatch_cli_install, + list_jobs as list_dispatch_jobs, list_targets as list_dispatch_targets, + probe_target as probe_dispatch_target, status as get_dispatch_status, + submit as submit_dispatch, DispatchConnectionRequest, DispatchInstallPollRequest, + DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, + DispatchListTargetsRequest, DispatchProbeTargetRequest, DispatchStatusRequest, + DispatchSubmitRequest, DispatchTargetOption, +}; +pub use target::{DispatchTarget, DispatchTargetRequest}; + +const PROMPT_PREVIEW_CHARS: usize = 160; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OutboundDispatchRecord { + pub job_id: String, + pub target: DispatchTarget, + pub session_id: String, + pub workspace_path: String, + pub prompt_preview: String, + pub last_cursor: u64, + pub last_state: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl OutboundDispatchRecord { + pub fn new( + job_id: String, + target: DispatchTarget, + session_id: String, + workspace_path: String, + prompt: &str, + state: impl Into, + ) -> Result { + validate_id(&job_id)?; + let now = Utc::now(); + Ok(Self { + job_id, + target, + session_id, + workspace_path, + prompt_preview: prompt.chars().take(PROMPT_PREVIEW_CHARS).collect(), + last_cursor: 0, + last_state: state.into(), + created_at: now, + updated_at: now, + }) + } +} + +#[derive(Debug, Error)] +pub enum DispatchStoreError { + #[error("Invalid dispatch job id")] + InvalidJobId, + #[error("Failed to access outbound dispatch index: {0}")] + Io(#[from] std::io::Error), + #[error("Failed to persist outbound dispatch index: {0}")] + Json(#[from] JsonFileStoreError), +} + +/// Durable observer-only index for jobs submitted to other BitFun processes. +/// +/// This store intentionally lives outside every workspace/session directory. +/// Writing a record here must never acquire runtime ownership or create a local +/// backend session. +#[derive(Debug, Clone)] +pub struct OutboundDispatchStore { + root: PathBuf, + json_store: JsonFileStore, +} + +impl OutboundDispatchStore { + pub fn new(path_manager: &PathManager) -> Self { + Self::from_root( + path_manager + .bitfun_home_dir() + .join("dispatch") + .join("outbound"), + ) + } + + fn from_root(root: PathBuf) -> Self { + Self { + root, + json_store: JsonFileStore, + } + } + + #[cfg(test)] + pub(crate) fn new_in_root_for_tests(root: PathBuf) -> Self { + Self::from_root(root) + } + + pub fn root(&self) -> &Path { + &self.root + } + + /// Atomically bind a job id to its first outbound record. + /// + /// Dispatch submission is idempotent across renderer retries and may race + /// across multiple controller processes. The first binding wins; callers + /// must compare the returned record with their requested target/session + /// before contacting a target. + pub async fn bind_if_absent( + &self, + record: &OutboundDispatchRecord, + ) -> Result { + let path = self.record_path(&record.job_id)?; + self.ensure_root().await?; + let _lock = self.json_store.acquire_cross_process_lock(&path).await?; + if let Some(existing) = self + .json_store + .read_optional::(&path) + .await? + { + return Ok(existing); + } + self.json_store.write_atomic_strict(&path, record).await?; + harden_file_permissions(&path).await?; + Ok(record.clone()) + } + + pub async fn get( + &self, + job_id: &str, + ) -> Result, DispatchStoreError> { + let path = self.record_path(job_id)?; + Ok(self.json_store.read_optional(&path).await?) + } + + pub async fn update_progress( + &self, + job_id: &str, + cursor: u64, + state: impl Into, + ) -> Result { + let path = self.record_path(job_id)?; + let _lock = self.json_store.acquire_cross_process_lock(&path).await?; + let mut record = self + .json_store + .read_optional::(&path) + .await? + .ok_or_else(|| { + DispatchStoreError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("dispatch job {job_id} is not in the outbound index"), + )) + })?; + record.last_cursor = record.last_cursor.max(cursor); + let next_state = state.into(); + if !is_terminal_state(&record.last_state) || record.last_state == next_state { + record.last_state = next_state; + } + record.updated_at = Utc::now(); + self.json_store.write_atomic_strict(&path, &record).await?; + harden_file_permissions(&path).await?; + Ok(record) + } + + pub async fn list(&self) -> Result, DispatchStoreError> { + let mut entries = match fs::read_dir(&self.root).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error.into()), + }; + let mut records = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") + || !entry.file_type().await?.is_file() + { + continue; + } + match self + .json_store + .read_optional::(&path) + .await + { + Ok(Some(record)) => records.push(record), + Ok(None) => {} + Err(error) => { + log::warn!( + "Skipping unreadable outbound dispatch record: path={} error={}", + path.display(), + error + ); + } + } + } + records.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.job_id.cmp(&right.job_id)) + }); + Ok(records) + } + + pub async fn remove(&self, job_id: &str) -> Result { + let path = self.record_path(job_id)?; + let _lock = self.json_store.acquire_cross_process_lock(&path).await?; + match fs::remove_file(path).await { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } + } + + fn record_path(&self, job_id: &str) -> Result { + validate_id(job_id)?; + Ok(self.root.join(format!("{job_id}.json"))) + } + + async fn ensure_root(&self) -> Result<(), DispatchStoreError> { + fs::create_dir_all(&self.root).await?; + harden_directory_permissions(&self.root).await?; + Ok(()) + } +} + +fn validate_id(value: &str) -> Result<(), DispatchStoreError> { + if value.is_empty() + || value.len() > 128 + || value == "." + || value == ".." + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(DispatchStoreError::InvalidJobId); + } + Ok(()) +} + +fn is_terminal_state(state: &str) -> bool { + matches!(state, "succeeded" | "failed" | "cancelled") +} + +#[cfg(unix)] +async fn harden_directory_permissions(path: &Path) -> Result<(), std::io::Error> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).await +} + +#[cfg(not(unix))] +async fn harden_directory_permissions(_path: &Path) -> Result<(), std::io::Error> { + Ok(()) +} + +#[cfg(unix)] +async fn harden_file_permissions(path: &Path) -> Result<(), std::io::Error> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await +} + +#[cfg(not(unix))] +async fn harden_file_permissions(_path: &Path) -> Result<(), std::io::Error> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn target() -> DispatchTarget { + DispatchTarget::Ssh { + connection_id: "server-a".to_string(), + workspace_path: "/srv/app".to_string(), + display_name: "Build server".to_string(), + } + } + + #[tokio::test] + async fn outbound_index_is_separate_and_cursor_is_monotonic() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = OutboundDispatchStore::from_root(temp.path().join("dispatch/outbound")); + let record = OutboundDispatchRecord::new( + "job-1".to_string(), + target(), + "session-1".to_string(), + "/srv/app".to_string(), + "Summarize the repository", + "queued", + ) + .expect("record"); + + store.bind_if_absent(&record).await.expect("persist"); + let first = store + .update_progress("job-1", 42, "running") + .await + .expect("first progress"); + let stale = store + .update_progress("job-1", 12, "running") + .await + .expect("stale progress"); + let terminal = store + .update_progress("job-1", 50, "succeeded") + .await + .expect("terminal progress"); + let regressed = store + .update_progress("job-1", 55, "running") + .await + .expect("stale state after terminal"); + + assert_eq!(first.last_cursor, 42); + assert_eq!(stale.last_cursor, 42); + assert_eq!(terminal.last_state, "succeeded"); + assert_eq!(regressed.last_state, "succeeded"); + assert_eq!(regressed.last_cursor, 55); + assert_eq!(store.list().await.expect("list").len(), 1); + assert!(store.root().ends_with("dispatch/outbound")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(store.root()) + .expect("outbound directory") + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + std::fs::metadata(store.root().join("job-1.json")) + .expect("outbound record") + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + + #[tokio::test] + async fn rejects_path_traversal_job_ids() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = OutboundDispatchStore::from_root(temp.path().to_path_buf()); + let error = store.get("../sessions").await.expect_err("must reject"); + assert!(matches!(error, DispatchStoreError::InvalidJobId)); + } + + #[tokio::test] + async fn concurrent_job_binding_has_one_immutable_winner() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = OutboundDispatchStore::from_root(temp.path().to_path_buf()); + let first = OutboundDispatchRecord::new( + "job-1".to_string(), + target(), + "session-1".to_string(), + "/srv/app".to_string(), + "first", + "submitting", + ) + .expect("first record"); + let mut conflicting = first.clone(); + conflicting.session_id = "session-2".to_string(); + + let (left, right) = tokio::join!( + store.bind_if_absent(&first), + store.bind_if_absent(&conflicting) + ); + let left = left.expect("left bind"); + let right = right.expect("right bind"); + let persisted = store + .get("job-1") + .await + .expect("read") + .expect("persisted winner"); + assert_eq!(left, persisted); + assert_eq!(right, persisted); + assert!( + persisted == first || persisted == conflicting, + "the persisted record must be one complete contender" + ); + } + + #[test] + fn prompt_preview_is_unicode_safe_and_bounded() { + let prompt = "界".repeat(PROMPT_PREVIEW_CHARS + 10); + let record = OutboundDispatchRecord::new( + "job-1".to_string(), + target(), + "session-1".to_string(), + "/srv/app".to_string(), + &prompt, + "queued", + ) + .expect("record"); + assert_eq!(record.prompt_preview.chars().count(), PROMPT_PREVIEW_CHARS); + } +} diff --git a/src/crates/assembly/core/src/service/dispatch/target.rs b/src/crates/assembly/core/src/service/dispatch/target.rs new file mode 100644 index 0000000000..dc5a3aac93 --- /dev/null +++ b/src/crates/assembly/core/src/service/dispatch/target.rs @@ -0,0 +1,109 @@ +use serde::{Deserialize, Serialize}; + +/// The execution location selected while a chat session is being created. +/// +/// Dispatch is deliberately orthogonal to `SessionExecutionTarget`: the latter +/// describes a path owned by this process, while non-local dispatch targets are +/// owned by another BitFun process. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum DispatchTargetRequest { + Local, + Ssh { + #[serde(rename = "connectionId")] + connection_id: String, + #[serde(rename = "workspacePath")] + workspace_path: String, + }, + Device { + #[serde(rename = "deviceId")] + device_id: String, + #[serde(rename = "workspacePath")] + workspace_path: String, + }, +} + +impl Default for DispatchTargetRequest { + fn default() -> Self { + Self::Local + } +} + +impl DispatchTargetRequest { + pub fn is_local(&self) -> bool { + matches!(self, Self::Local) + } + + pub fn workspace_path(&self) -> Option<&str> { + match self { + Self::Local => None, + Self::Ssh { workspace_path, .. } | Self::Device { workspace_path, .. } => { + Some(workspace_path) + } + } + } +} + +/// Resolved dispatch target persisted with an outbound observer record. +/// +/// `display_name` is presentation-only. Stable routing always uses the +/// connection/device id and never the label. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum DispatchTarget { + Local, + Ssh { + #[serde(rename = "connectionId")] + connection_id: String, + #[serde(rename = "workspacePath")] + workspace_path: String, + #[serde(rename = "displayName")] + display_name: String, + }, + Device { + #[serde(rename = "deviceId")] + device_id: String, + #[serde(rename = "workspacePath")] + workspace_path: String, + #[serde(rename = "displayName")] + display_name: String, + }, +} + +impl DispatchTarget { + pub fn is_local(&self) -> bool { + matches!(self, Self::Local) + } + + pub fn workspace_path(&self) -> Option<&str> { + match self { + Self::Local => None, + Self::Ssh { workspace_path, .. } | Self::Device { workspace_path, .. } => { + Some(workspace_path) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dispatch_target_shape_is_tagged_and_camel_case() { + let value = serde_json::to_value(DispatchTargetRequest::Ssh { + connection_id: "server-a".to_string(), + workspace_path: "/srv/app".to_string(), + }) + .expect("serialize target"); + + assert_eq!( + value, + serde_json::json!({ + "kind": "ssh", + "connectionId": "server-a", + "workspacePath": "/srv/app" + }) + ); + } +} diff --git a/src/crates/assembly/core/src/service/mod.rs b/src/crates/assembly/core/src/service/mod.rs index b56f1bab93..66fb2202fa 100644 --- a/src/crates/assembly/core/src/service/mod.rs +++ b/src/crates/assembly/core/src/service/mod.rs @@ -12,6 +12,7 @@ pub mod canvas; // Canvas service compatibility facade pub mod config; // Config management #[cfg(feature = "product-full")] pub mod cron; // Scheduled jobs +pub mod dispatch; // Outbound dispatch observer index and target contracts pub mod filesystem; // FileSystem management #[cfg(feature = "service-integrations")] pub mod git; // Git service diff --git a/src/crates/assembly/core/src/service/remote_ssh/mod.rs b/src/crates/assembly/core/src/service/remote_ssh/mod.rs index 4afa87e1fe..d00635410e 100644 --- a/src/crates/assembly/core/src/service/remote_ssh/mod.rs +++ b/src/crates/assembly/core/src/service/remote_ssh/mod.rs @@ -13,9 +13,9 @@ pub mod remote_terminal; pub mod types; pub mod workspace_state; -#[cfg(feature = "ssh-remote")] -pub use bitfun_services_integrations::remote_ssh::relay_deploy; pub use bitfun_services_integrations::remote_ssh::{build_remote_git_command, shell_quote_posix}; +#[cfg(feature = "ssh-remote")] +pub use bitfun_services_integrations::remote_ssh::{dispatch_ssh, relay_deploy}; #[cfg(not(feature = "ssh-remote"))] pub use bitfun_services_integrations::remote_ssh::{ get_global_remote_exec_process_manager, KnownHostEntry, PTYSession, PortForward, diff --git a/src/crates/services/services-integrations/src/remote_ssh/disabled.rs b/src/crates/services/services-integrations/src/remote_ssh/disabled.rs index 360239d98b..98dc9f6b25 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/disabled.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/disabled.rs @@ -17,6 +17,129 @@ fn unsupported() -> anyhow::Error { anyhow::anyhow!("Remote SSH support is disabled; enable the `ssh-remote` feature") } +/// Disabled mirror of the concrete SSH dispatch transport. +/// +/// Keeping the DTOs and function signatures available lets lightweight builds +/// compile shared command adapters while every runtime operation still fails +/// explicitly. +pub mod dispatch_ssh { + use super::{unsupported, SSHConnectionManager}; + use serde::{Deserialize, Serialize}; + use serde_json::Value; + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct DispatchCliRelease { + pub version: String, + pub target: String, + pub url: String, + pub sha256: String, + } + + #[derive(Debug, Clone, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct DispatchSshProbe { + pub cli_installed: bool, + pub cli_path: Option, + pub os: String, + pub arch: String, + pub install_supported: bool, + pub install_error: Option, + pub protocol_error: Option, + pub release: Option, + pub protocol: Option, + } + + #[derive(Debug, Clone, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct DispatchInstallStart { + pub script_path: String, + pub version: String, + pub target: String, + pub url: String, + pub sha256: String, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum DispatchInstallStatus { + Running, + Succeeded, + Failed, + } + + #[derive(Debug, Clone, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct DispatchInstallPoll { + pub cursor: u64, + pub output: String, + pub status: DispatchInstallStatus, + } + + pub async fn probe( + _manager: &SSHConnectionManager, + _connection_id: &str, + _workspace_path: Option<&str>, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn install_cli_start( + _manager: &SSHConnectionManager, + _connection_id: &str, + _expected_release: &DispatchCliRelease, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn install_cli_poll( + _manager: &SSHConnectionManager, + _connection_id: &str, + _cursor: u64, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn install_cli_cancel( + _manager: &SSHConnectionManager, + _connection_id: &str, + ) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn submit( + _manager: &SSHConnectionManager, + _connection_id: &str, + _request: &Value, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn status( + _manager: &SSHConnectionManager, + _connection_id: &str, + _request: &Value, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn cancel( + _manager: &SSHConnectionManager, + _connection_id: &str, + _request: &Value, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn list( + _manager: &SSHConnectionManager, + _connection_id: &str, + _request: &Value, + ) -> anyhow::Result { + Err(unsupported()) + } +} + static GLOBAL_REMOTE_EXEC_MANAGER: OnceLock> = OnceLock::new(); pub fn get_global_remote_exec_process_manager() -> Arc { diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs new file mode 100644 index 0000000000..6b7aeed834 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -0,0 +1,1607 @@ +//! SSH transport for persistent BitFun dispatch jobs. +//! +//! The target-side runner is the `bitfun dispatch` CLI surface. This module is +//! deliberately only a submit/poll transport: the remote CLI owns jobs, +//! sessions, transcripts, process detachment, and cancellation semantics. +//! +//! Installing the CLI is a separate, explicit operation. `probe` never installs +//! anything; `install_cli_start` downloads an official archive locally, verifies +//! both its signed SHA256 sidecar and archive minisign signature, then stages it +//! under the SSH user's home before starting an owner-only installer. + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::time::Duration; + +use super::manager::SSHConnectionManager; +use super::release_verify::{ + release_tag_for_version, require_release_pubkey, verify_minisign, verify_sha256, + verify_signed_checksum, +}; +use super::remote_git::shell_quote_posix; +use super::types::SSHCommandOptions; + +const RELEASE_BASE: &str = "https://github.com/GCWing/BitFun/releases"; +const RELEASE_VERSION: &str = env!("CARGO_PKG_VERSION"); +const INSTALL_STATE_DIR: &str = ".bitfun/dispatch/install"; +const REQUEST_STATE_DIR: &str = ".bitfun/dispatch/requests"; +const INSTALL_STEM: &str = "install-cli"; +const INSTALL_DONE_MARKER: &str = "BITFUN_DISPATCH_CLI_INSTALL_DONE"; +const INSTALL_PREPARE_GRACE_SECONDS: u64 = 30; +const COMMAND_TIMEOUT_MS: u64 = 30_000; +const RELEASE_READ_TIMEOUT_SECONDS: u64 = 30; +const MAX_ARCHIVE_BYTES: usize = 512 * 1024 * 1024; +const DISPATCH_PROTOCOL_VERSION: u64 = 1; +const REQUIRED_DISPATCH_CAPABILITIES: [&str; 7] = [ + "persistent_jobs", + "cursor_events", + "detached_worker", + "workspace_serialization", + "frontend_event_projection", + "approval_auto", + "approval_reject_and_report", +]; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchCliRelease { + pub version: String, + pub target: String, + pub url: String, + pub sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchSshProbe { + pub cli_installed: bool, + pub cli_path: Option, + pub os: String, + pub arch: String, + pub install_supported: bool, + pub install_error: Option, + pub protocol_error: Option, + pub release: Option, + pub protocol: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchInstallStart { + /// Absolute path of the staged driver. The task has already been launched; + /// this path is returned for diagnostics and must not be executed again. + pub script_path: String, + pub version: String, + pub target: String, + pub url: String, + pub sha256: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DispatchInstallStatus { + Running, + Succeeded, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchInstallPoll { + pub cursor: u64, + pub output: String, + pub status: DispatchInstallStatus, +} + +#[derive(Debug)] +struct RemoteTarget { + os: String, + arch: String, + home: String, + cli_path: Option, + tar_available: bool, +} + +#[derive(Debug)] +struct ResolvedRelease { + public: DispatchCliRelease, + filename: String, + checksum_url: String, + checksum_signature_url: String, + archive_signature_url: String, +} + +/// Probe the remote OS/architecture and, when present, the target CLI dispatch +/// protocol. A missing or old CLI is a normal result rather than an error. +/// +/// Release metadata is resolved only when installation or upgrade is needed. +/// Resolving it verifies the signature over the SHA256 sidecar so the UI can +/// safely show the exact URL, version, and digest before asking for consent. +pub async fn probe( + manager: &SSHConnectionManager, + connection_id: &str, + workspace_path: Option<&str>, +) -> Result { + ensure_plain_ssh_target(manager, connection_id).await?; + let target = probe_remote_target(manager, connection_id).await?; + + let mut protocol = None; + let mut protocol_error = None; + if let Some(cli_path) = target.cli_path.as_deref() { + let request = workspace_path + .map(|path| serde_json::json!({ "workspacePath": path })) + .unwrap_or_else(|| serde_json::json!({})); + match invoke_json_at_path( + manager, + connection_id, + &target.home, + cli_path, + "probe", + &request, + ) + .await + { + Ok(response) => protocol = Some(response), + Err(error) => protocol_error = Some(error.to_string()), + } + } + + let needs_install = target.cli_path.is_none() + || !protocol + .as_ref() + .is_some_and(dispatch_protocol_is_compatible); + let (release, install_error) = if needs_install { + if !target.tar_available { + ( + None, + Some("remote target has no tar executable; install tar and retry".to_string()), + ) + } else { + match resolve_release(&target.os, &target.arch).await { + Ok(release) => (Some(release.public), None), + Err(error) => (None, Some(error.to_string())), + } + } + } else { + (None, None) + }; + let install_supported = release.is_some(); + + Ok(DispatchSshProbe { + cli_installed: target.cli_path.is_some(), + cli_path: target.cli_path, + os: target.os, + arch: target.arch, + install_supported, + install_error, + protocol_error, + release, + protocol, + }) +} + +fn dispatch_protocol_is_compatible(protocol: &Value) -> bool { + validate_dispatch_protocol(protocol, None).is_ok() +} + +/// Validate the target-side protocol immediately before submission. +/// +/// `approval_policy = None` is used by installation probing and requires the +/// complete phase-one surface. Submission may validate only the selected +/// unattended approval behavior in addition to the transport invariants. +pub fn validate_dispatch_protocol(protocol: &Value, approval_policy: Option<&str>) -> Result<()> { + if protocol.get("protocolVersion").and_then(Value::as_u64) != Some(DISPATCH_PROTOCOL_VERSION) { + return Err(anyhow!( + "dispatch protocol version is incompatible; expected {}", + DISPATCH_PROTOCOL_VERSION + )); + } + let Some(capabilities) = protocol.get("capabilities").and_then(Value::as_array) else { + return Err(anyhow!("dispatch target returned no capability list")); + }; + let required: &[&str] = match approval_policy { + Some("auto") => &[ + "persistent_jobs", + "cursor_events", + "detached_worker", + "workspace_serialization", + "frontend_event_projection", + "approval_auto", + ], + Some("reject-and-report") => &[ + "persistent_jobs", + "cursor_events", + "detached_worker", + "workspace_serialization", + "frontend_event_projection", + "approval_reject_and_report", + ], + Some(_) => return Err(anyhow!("unsupported dispatch approval policy")), + None => &REQUIRED_DISPATCH_CAPABILITIES, + }; + let missing = required + .iter() + .copied() + .filter(|required| { + !capabilities + .iter() + .any(|capability| capability.as_str() == Some(*required)) + }) + .collect::>(); + if !missing.is_empty() { + return Err(anyhow!( + "dispatch target is missing required capabilities: {}", + missing.join(", ") + )); + } + Ok(()) +} + +/// Explicitly install the matching BitFun CLI release on the SSH target. +/// +/// This function fails closed when the build has no release trust root, a +/// checksum/signature is absent, or either verification fails. It never uses +/// sudo and writes only below `~/.local/bin` and `~/.bitfun`. +pub async fn install_cli_start( + manager: &SSHConnectionManager, + connection_id: &str, + expected_release: &DispatchCliRelease, +) -> Result { + ensure_plain_ssh_target(manager, connection_id).await?; + let target = probe_remote_target(manager, connection_id).await?; + if !target.tar_available { + return Err(anyhow!( + "remote target has no tar executable; install tar and retry" + )); + } + let release = resolve_release(&target.os, &target.arch).await?; + ensure_confirmed_release(&release.public, expected_release)?; + let archive = download_verified_archive(&release).await?; + + // Stop an earlier attempt before replacing any of its staged files. A + // cancellation transport error is not safe to ignore: the old installer + // may still be reading the archive or paths this attempt would replace. + install_cli_cancel(manager, connection_id) + .await + .context("stop an earlier BitFun CLI installation")?; + + let dir = format!("{}/{}", target.home, INSTALL_STATE_DIR); + let archive_path = format!("{dir}/{}", release.filename); + let body_path = format!("{dir}/{INSTALL_STEM}-body.sh"); + let script_path = format!("{dir}/{INSTALL_STEM}.sh"); + let log_path = format!("{dir}/{INSTALL_STEM}.log"); + let pid_path = format!("{dir}/{INSTALL_STEM}.pid"); + let driver_pid_path = format!("{dir}/{INSTALL_STEM}.driver.pid"); + let prepare_path = format!("{dir}/{INSTALL_STEM}.preparing"); + let exit_path = format!("{dir}/{INSTALL_STEM}.exit"); + let install_token = format!("bitfun-install-{}", uuid::Uuid::new_v4().as_simple()); + + exec_ok( + manager, + connection_id, + &format!( + "mkdir -p {dir} && chmod 700 {root} {dispatch} {dir}", + root = shell_quote_posix(&format!("{}/.bitfun", target.home)), + dispatch = shell_quote_posix(&format!("{}/.bitfun/dispatch", target.home)), + dir = shell_quote_posix(&dir), + ), + ) + .await?; + + let body = to_unix_script(&install_body_script( + &dir, + &archive_path, + &release.public.version, + )); + let driver = to_unix_script(&install_driver_script(&dir, &body_path, &install_token)); + manager + .sftp_write(connection_id, &archive_path, &archive) + .await + .context("stage verified BitFun CLI archive")?; + manager + .sftp_write(connection_id, &body_path, body.as_bytes()) + .await + .context("stage BitFun CLI install body")?; + manager + .sftp_write(connection_id, &script_path, driver.as_bytes()) + .await + .context("stage BitFun CLI install driver")?; + + exec_ok( + manager, + connection_id, + &stage_install_command( + &archive_path, + &body_path, + &script_path, + &log_path, + &pid_path, + &driver_pid_path, + &prepare_path, + &exit_path, + &install_token, + ), + ) + .await?; + + // The short-lived PTY driver only starts a nohup body and exits. Draining + // the channel in the background prevents a server-side channel leak while + // keeping the installer independent of the caller process. + let channel = match manager + .open_pty_exec_channel( + connection_id, + &format!( + "bash {} {}", + shell_quote_posix(&script_path), + shell_quote_posix(&install_token) + ), + 100, + 30, + ) + .await + { + Ok(channel) => channel, + Err(error) => { + let _ = install_cli_cancel(manager, connection_id).await; + return Err(error).context("start remote BitFun CLI installer"); + } + }; + tokio::spawn(async move { + let mut channel = channel; + while channel.wait().await.is_some() {} + }); + + Ok(DispatchInstallStart { + script_path, + version: release.public.version, + target: release.public.target, + url: release.public.url, + sha256: release.public.sha256, + }) +} + +fn ensure_confirmed_release( + resolved: &DispatchCliRelease, + expected: &DispatchCliRelease, +) -> Result<()> { + if resolved != expected { + return Err(anyhow!( + "BitFun CLI release metadata changed after confirmation; probe the target and confirm the new asset" + )); + } + Ok(()) +} + +pub async fn install_cli_poll( + manager: &SSHConnectionManager, + connection_id: &str, + cursor: u64, +) -> Result { + ensure_plain_ssh_target(manager, connection_id).await?; + let script = install_poll_script(cursor); + let result = manager + .execute_command_with_options( + connection_id, + &script, + SSHCommandOptions { + timeout_ms: Some(COMMAND_TIMEOUT_MS), + cancellation_token: None, + }, + ) + .await?; + ensure_command_completed(&result, "poll CLI install")?; + if result.exit_code != 0 { + return Err(remote_command_error( + "poll CLI install", + result.exit_code, + &result.stdout, + &result.stderr, + )); + } + let (head, output) = split_metadata_output(&result.stdout); + let value = |key: &str| { + head.lines() + .find_map(|line| { + line.strip_prefix(key) + .and_then(|rest| rest.strip_prefix('=')) + }) + .unwrap_or("") + .trim() + }; + let running = value("running") == "1"; + let preparing = value("preparing") == "1"; + let marker = value("marker") == "1"; + let exit_recorded = value("exit_recorded") == "1"; + let exit_code = value("exit_code").parse::().ok(); + let size = value("size").parse::().unwrap_or(cursor); + let status = if marker { + DispatchInstallStatus::Succeeded + } else if running || preparing { + DispatchInstallStatus::Running + } else if exit_recorded || size > 0 { + DispatchInstallStatus::Failed + } else { + // start seeds the prepare flag before launching the driver, so an empty + // state after an explicit start means the driver never became live. + DispatchInstallStatus::Failed + }; + let mut output = output.to_string(); + if status == DispatchInstallStatus::Failed && exit_code == Some(130) && output.is_empty() { + output.push_str("BitFun CLI installation was cancelled.\n"); + } + Ok(DispatchInstallPoll { + cursor: size, + output, + status, + }) +} + +/// Best-effort process-tree cancellation for an in-flight CLI install. +pub async fn install_cli_cancel(manager: &SSHConnectionManager, connection_id: &str) -> Result<()> { + ensure_plain_ssh_target(manager, connection_id).await?; + let script = install_cancel_script(); + let result = manager + .execute_command_with_options( + connection_id, + &script, + SSHCommandOptions { + timeout_ms: Some(COMMAND_TIMEOUT_MS), + cancellation_token: None, + }, + ) + .await?; + ensure_command_completed(&result, "cancel CLI install")?; + if result.exit_code != 0 { + return Err(remote_command_error( + "cancel CLI install", + result.exit_code, + &result.stdout, + &result.stderr, + )); + } + Ok(()) +} + +pub async fn submit( + manager: &SSHConnectionManager, + connection_id: &str, + request: &Value, +) -> Result { + invoke_json(manager, connection_id, "submit", request).await +} + +pub async fn status( + manager: &SSHConnectionManager, + connection_id: &str, + request: &Value, +) -> Result { + invoke_json(manager, connection_id, "status", request).await +} + +pub async fn cancel( + manager: &SSHConnectionManager, + connection_id: &str, + request: &Value, +) -> Result { + invoke_json(manager, connection_id, "cancel", request).await +} + +pub async fn list( + manager: &SSHConnectionManager, + connection_id: &str, + request: &Value, +) -> Result { + invoke_json(manager, connection_id, "list", request).await +} + +async fn invoke_json( + manager: &SSHConnectionManager, + connection_id: &str, + verb: &'static str, + request: &Value, +) -> Result { + ensure_plain_ssh_target(manager, connection_id).await?; + let target = probe_remote_target(manager, connection_id).await?; + let cli_path = target.cli_path.as_deref().ok_or_else(|| { + anyhow!("BitFun CLI is not installed on the SSH target; confirm installation first") + })?; + invoke_json_at_path( + manager, + connection_id, + &target.home, + cli_path, + verb, + request, + ) + .await +} + +/// Transfer the request as an owner-only file instead of embedding its JSON in +/// the SSH command. Prompts can contain credentials or private source context; +/// command arguments would expose them to process listings and manager debug +/// previews on both machines. +async fn invoke_json_at_path( + manager: &SSHConnectionManager, + connection_id: &str, + home: &str, + cli_path: &str, + verb: &'static str, + request: &Value, +) -> Result { + let request_dir = format!("{home}/{REQUEST_STATE_DIR}"); + exec_ok( + manager, + connection_id, + &format!( + "mkdir -p {dir} && chmod 700 {root} {dispatch} {dir}", + root = shell_quote_posix(&format!("{home}/.bitfun")), + dispatch = shell_quote_posix(&format!("{home}/.bitfun/dispatch")), + dir = shell_quote_posix(&request_dir) + ), + ) + .await?; + let request_path = format!("{request_dir}/{}.json", uuid::Uuid::new_v4().as_simple()); + let request_bytes = serde_json::to_vec(request).context("serialize dispatch request")?; + // Pre-create with 0600 before SFTP opens it. Creating first and chmodding + // afterwards would leave a prompt briefly governed by the server's umask + // (commonly 0644). + exec_ok( + manager, + connection_id, + &format!( + "umask 077; : > {request}; chmod 600 {request}", + request = shell_quote_posix(&request_path) + ), + ) + .await?; + if let Err(error) = manager + .sftp_write(connection_id, &request_path, &request_bytes) + .await + .context("stage dispatch request") + { + let _ = manager.sftp_remove(connection_id, &request_path).await; + return Err(error); + } + + let command = dispatch_command(cli_path, verb, &request_path); + let result = manager + .execute_command_with_options( + connection_id, + &command, + SSHCommandOptions { + timeout_ms: Some(COMMAND_TIMEOUT_MS), + cancellation_token: None, + }, + ) + .await; + // The remote EXIT trap normally removes it. This covers channel-open and + // transport failures before the shell installed that trap. + let _ = manager.sftp_remove(connection_id, &request_path).await; + let result = result?; + ensure_command_completed(&result, &format!("dispatch {verb}"))?; + if result.exit_code != 0 { + return Err(remote_command_error( + &format!("dispatch {verb}"), + result.exit_code, + &result.stdout, + &result.stderr, + )); + } + serde_json::from_str(result.stdout.trim()).with_context(|| { + format!( + "dispatch {verb} returned invalid JSON: {}", + bounded_detail(&result.stdout) + ) + }) +} + +fn dispatch_command(cli_path: &str, verb: &str, request_path: &str) -> String { + let cli = shell_quote_posix(cli_path); + let request = shell_quote_posix(request_path); + let verb = shell_quote_posix(verb); + format!( + "request={request}; \ + cleanup() {{ rm -f \"$request\"; }}; \ + trap cleanup EXIT; \ + trap 'exit 130' HUP INT TERM; \ + {cli} dispatch {verb} < \"$request\"" + ) +} + +async fn ensure_plain_ssh_target( + manager: &SSHConnectionManager, + connection_id: &str, +) -> Result<()> { + let active_container = manager + .get_connection_config(connection_id) + .await + .is_some_and(|config| config.container.is_some()); + let saved_container = manager + .get_saved_connections() + .await + .into_iter() + .find(|config| config.id == connection_id) + .is_some_and(|config| config.container.is_some()); + if active_container || saved_container { + return Err(anyhow!( + "SSH dispatch does not support Docker-container connection targets" + )); + } + Ok(()) +} + +async fn probe_remote_target( + manager: &SSHConnectionManager, + connection_id: &str, +) -> Result { + let script = probe_remote_target_script(); + let result = manager + .execute_command_with_options( + connection_id, + script, + SSHCommandOptions { + timeout_ms: Some(COMMAND_TIMEOUT_MS), + cancellation_token: None, + }, + ) + .await?; + ensure_command_completed(&result, "probe SSH dispatch target")?; + if result.exit_code != 0 { + return Err(remote_command_error( + "probe SSH dispatch target", + result.exit_code, + &result.stdout, + &result.stderr, + )); + } + let get = |key: &str| { + result + .stdout + .lines() + .find_map(|line| { + line.strip_prefix(key) + .and_then(|rest| rest.strip_prefix('=')) + }) + .unwrap_or("") + .trim() + .to_string() + }; + let home = get("home"); + if home.is_empty() { + return Err(anyhow!("could not resolve remote $HOME")); + } + let cli_path = get("cli"); + Ok(RemoteTarget { + os: get("os"), + arch: get("arch"), + home, + cli_path: (!cli_path.is_empty()).then_some(cli_path), + tar_available: get("tar") == "1", + }) +} + +fn probe_remote_target_script() -> &'static str { + r#" +LC_ALL=C +printf 'os=%s\n' "$(uname -s 2>/dev/null || true)" +printf 'arch=%s\n' "$(uname -m 2>/dev/null || true)" +printf 'home=%s\n' "$HOME" +if command -v tar >/dev/null 2>&1; then printf 'tar=1\n'; else printf 'tar=0\n'; fi +if [ -x "$HOME/.local/bin/bitfun" ]; then + BITFUN_BIN="$HOME/.local/bin/bitfun" +else + BITFUN_BIN="$(command -v bitfun 2>/dev/null || true)" +fi +printf 'cli=%s\n' "$BITFUN_BIN" +"# +} + +async fn resolve_release(os: &str, arch: &str) -> Result { + let pubkey = require_release_pubkey()?; + let target = release_target(os, arch)?; + let version = RELEASE_VERSION.split('+').next().unwrap_or(RELEASE_VERSION); + let filename = format!("bitfun-cli-{version}-{target}.tar.gz"); + let tag = release_tag_for_version(RELEASE_VERSION); + let url = format!("{RELEASE_BASE}/download/{tag}/{filename}"); + let checksum_url = format!("{url}.sha256"); + let checksum_signature_url = format!("{checksum_url}.sig"); + let archive_signature_url = format!("{url}.sig"); + let client = release_http_client()?; + let checksum = fetch_required_text(&client, &checksum_url).await?; + let signature = fetch_required_text(&client, &checksum_signature_url).await?; + let sha256 = verify_signed_checksum(&checksum, &signature, pubkey, &filename)?; + + Ok(ResolvedRelease { + public: DispatchCliRelease { + version: version.to_string(), + target: target.to_string(), + url, + sha256, + }, + filename, + checksum_url, + checksum_signature_url, + archive_signature_url, + }) +} + +fn release_target(os: &str, arch: &str) -> Result<&'static str> { + match (os.trim(), arch.trim()) { + ("Linux", "x86_64" | "amd64") => Ok("x86_64-unknown-linux-gnu"), + ("Linux", "aarch64" | "arm64") => Ok("aarch64-unknown-linux-gnu"), + ("Darwin", "x86_64" | "amd64") => Ok("x86_64-apple-darwin"), + ("Darwin", "aarch64" | "arm64") => Ok("aarch64-apple-darwin"), + (os, arch) => Err(anyhow!( + "BitFun SSH dispatch CLI install does not support {os} {arch}" + )), + } +} + +fn release_http_client() -> Result { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + // Per-read rather than whole-request: a genuine slow link may take + // longer than an arbitrary archive deadline, but a stalled source must + // still fail instead of hanging the installer forever. + .read_timeout(Duration::from_secs(RELEASE_READ_TIMEOUT_SECONDS)) + .build() + .context("build BitFun release HTTP client") +} + +async fn fetch_required_text(client: &reqwest::Client, url: &str) -> Result { + client + .get(url) + .send() + .await + .with_context(|| format!("request {url}"))? + .error_for_status() + .with_context(|| format!("download {url}"))? + .text() + .await + .with_context(|| format!("read {url}")) +} + +async fn download_verified_archive(release: &ResolvedRelease) -> Result> { + let pubkey = require_release_pubkey()?; + let client = release_http_client()?; + + // Re-fetch and verify the signed sidecar at install time instead of trusting + // a possibly stale preflight result. + let checksum = fetch_required_text(&client, &release.checksum_url).await?; + let checksum_signature = fetch_required_text(&client, &release.checksum_signature_url).await?; + let expected = + verify_signed_checksum(&checksum, &checksum_signature, pubkey, &release.filename)?; + if !expected.eq_ignore_ascii_case(&release.public.sha256) { + return Err(anyhow!( + "release checksum changed after preflight; refusing to install" + )); + } + + let mut response = client + .get(&release.public.url) + .send() + .await + .with_context(|| format!("request {}", release.public.url))? + .error_for_status() + .with_context(|| format!("download {}", release.public.url))?; + if response + .content_length() + .is_some_and(|length| length > MAX_ARCHIVE_BYTES as u64) + { + return Err(anyhow!( + "BitFun CLI archive exceeds the {} MB safety limit", + MAX_ARCHIVE_BYTES / (1024 * 1024) + )); + } + let mut archive = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .with_context(|| format!("read {}", release.public.url))? + { + extend_bounded_archive(&mut archive, &chunk, MAX_ARCHIVE_BYTES)?; + } + verify_sha256(&archive, &expected, &release.filename)?; + + let archive_signature = fetch_required_text(&client, &release.archive_signature_url).await?; + verify_minisign(&archive, &archive_signature, pubkey)?; + Ok(archive) +} + +fn extend_bounded_archive(archive: &mut Vec, chunk: &[u8], limit: usize) -> Result<()> { + if archive.len().saturating_add(chunk.len()) > limit { + return Err(anyhow!( + "BitFun CLI archive exceeds the {} MB safety limit", + limit / (1024 * 1024) + )); + } + archive.extend_from_slice(chunk); + Ok(()) +} + +fn install_body_script(dir: &str, archive_path: &str, expected_version: &str) -> String { + format!( + r#"#!/bin/bash +set -euo pipefail +umask 077 +D={dir} +ARCHIVE={archive} +EXPECTED_VERSION={version} +TOKEN="${{1:-}}" +PIDF="$D/{INSTALL_STEM}.pid" +EXITF="$D/{INSTALL_STEM}.exit" +TMP="$D/unpack.$$" +PRIMARY_TARGET="$HOME/.local/bin/bitfun" +LEGACY_TARGET="$HOME/.local/bin/bitfun-cli" +PRIMARY_NEW="$HOME/.local/bin/.bitfun-dispatch-new-$$" +LEGACY_NEW="$HOME/.local/bin/.bitfun-cli-dispatch-new-$$" +PRIMARY_BACKUP="$D/previous-bitfun.$$" +LEGACY_BACKUP="$D/previous-bitfun-cli.$$" +HAD_PRIMARY=0 +HAD_LEGACY=0 +PRIMARY_INSTALLED=0 +LEGACY_INSTALLED=0 +COMMITTED=0 +rollback_install() {{ + if [ "$LEGACY_INSTALLED" = "1" ]; then rm -f "$LEGACY_TARGET"; fi + if [ "$PRIMARY_INSTALLED" = "1" ]; then rm -f "$PRIMARY_TARGET"; fi + if [ "$HAD_LEGACY" = "1" ] && [ -f "$LEGACY_BACKUP" ]; then + mv -f "$LEGACY_BACKUP" "$LEGACY_TARGET" \ + || echo "ERROR: could not restore previous bitfun-cli" >&2 + fi + if [ "$HAD_PRIMARY" = "1" ] && [ -f "$PRIMARY_BACKUP" ]; then + mv -f "$PRIMARY_BACKUP" "$PRIMARY_TARGET" \ + || echo "ERROR: could not restore previous bitfun" >&2 + fi +}} +finish() {{ + code=$? + trap - EXIT HUP INT TERM + if [ "$code" -ne 0 ] && [ "$COMMITTED" != "1" ]; then rollback_install; fi + rm -f "$PRIMARY_NEW" "$LEGACY_NEW" + if [ "$COMMITTED" = "1" ]; then rm -f "$PRIMARY_BACKUP" "$LEGACY_BACKUP"; fi + rm -rf "$TMP" + printf '%s\n' "$code" >"$EXITF" + marker_token="$(sed -n '2p' "$PIDF" 2>/dev/null | tr -d '[:space:]')" + if [ -n "$TOKEN" ] && [ "$marker_token" = "$TOKEN" ]; then rm -f "$PIDF"; fi + exit "$code" +}} +trap finish EXIT +trap 'exit 130' HUP INT TERM +rm -f "$EXITF" +mkdir -p "$TMP" "$HOME/.local/bin" "$HOME/.bitfun" +chmod 700 "$HOME/.bitfun" +tar -xzf "$ARCHIVE" -C "$TMP" +PRIMARY="" +LEGACY="" +for candidate in "$TMP"/*/bitfun; do + [ -f "$candidate" ] || continue + [ -z "$PRIMARY" ] || {{ echo "ERROR: archive contains multiple bitfun binaries" >&2; exit 1; }} + PRIMARY="$candidate" +done +for candidate in "$TMP"/*/bitfun-cli; do + [ -f "$candidate" ] || continue + [ -z "$LEGACY" ] || {{ echo "ERROR: archive contains multiple bitfun-cli binaries" >&2; exit 1; }} + LEGACY="$candidate" +done +[ -n "$PRIMARY" ] || {{ echo "ERROR: archive contains no bitfun binary" >&2; exit 1; }} +[ -n "$LEGACY" ] || {{ echo "ERROR: archive contains no bitfun-cli binary" >&2; exit 1; }} +cp "$PRIMARY" "$PRIMARY_NEW" +cp "$LEGACY" "$LEGACY_NEW" +chmod 755 "$PRIMARY_NEW" "$LEGACY_NEW" +staged="$("$PRIMARY_NEW" --version 2>/dev/null || true)" +case "$staged" in + *"$EXPECTED_VERSION"*) ;; + *) echo "ERROR: staged CLI version did not match $EXPECTED_VERSION: $staged" >&2; exit 1 ;; +esac +"$LEGACY_NEW" --version >/dev/null 2>&1 \ + || {{ echo "ERROR: staged bitfun-cli companion did not run" >&2; exit 1; }} +if [ -e "$PRIMARY_TARGET" ]; then + mv -f "$PRIMARY_TARGET" "$PRIMARY_BACKUP" + HAD_PRIMARY=1 +fi +if [ -e "$LEGACY_TARGET" ]; then + mv -f "$LEGACY_TARGET" "$LEGACY_BACKUP" + HAD_LEGACY=1 +fi +mv -f "$PRIMARY_NEW" "$PRIMARY_TARGET" +PRIMARY_INSTALLED=1 +mv -f "$LEGACY_NEW" "$LEGACY_TARGET" +LEGACY_INSTALLED=1 +installed="$("$PRIMARY_TARGET" --version 2>/dev/null || true)" +case "$installed" in + *"$EXPECTED_VERSION"*) ;; + *) echo "ERROR: installed CLI version did not match $EXPECTED_VERSION: $installed" >&2; exit 1 ;; +esac +"$LEGACY_TARGET" --version >/dev/null 2>&1 \ + || {{ echo "ERROR: installed bitfun-cli companion did not run" >&2; exit 1; }} +COMMITTED=1 +rm -f "$ARCHIVE" +echo "Installed $installed at $HOME/.local/bin/bitfun" +echo {INSTALL_DONE_MARKER} +"#, + dir = shell_quote_posix(dir), + archive = shell_quote_posix(archive_path), + version = shell_quote_posix(expected_version), + ) +} + +fn install_driver_script(dir: &str, body_path: &str, install_token: &str) -> String { + format!( + r#"#!/bin/bash +set -euo pipefail +umask 077 +D={dir} +BODY={body} +EXPECTED_TOKEN={token} +TOKEN="${{1:-}}" +LOG="$D/{INSTALL_STEM}.log" +PIDF="$D/{INSTALL_STEM}.pid" +PIDF_TMP="$PIDF.$$" +DRIVER_PIDF="$D/{INSTALL_STEM}.driver.pid" +DRIVER_PIDF_TMP="$DRIVER_PIDF.$$" +PREPF="$D/{INSTALL_STEM}.preparing" +EXITF="$D/{INSTALL_STEM}.exit" +body_pid= +[ "$TOKEN" = "$EXPECTED_TOKEN" ] || exit 2 +cleanup_prepare() {{ + if [ -n "$body_pid" ] && [ ! -f "$PIDF" ]; then + kill "$body_pid" 2>/dev/null || true + fi + prepare_token="$(tr -d '[:space:]' < "$PREPF" 2>/dev/null || true)" + if [ "$prepare_token" = "$TOKEN" ]; then rm -f "$PREPF"; fi + driver_marker_token="$(sed -n '2p' "$DRIVER_PIDF" 2>/dev/null | tr -d '[:space:]')" + if [ "$driver_marker_token" = "$TOKEN" ]; then rm -f "$DRIVER_PIDF"; fi + rm -f "$PIDF_TMP" "$DRIVER_PIDF_TMP" +}} +cancel_prepare() {{ cleanup_prepare; exit 130; }} +trap cleanup_prepare EXIT +trap cancel_prepare HUP INT TERM +printf '%s\n%s\n' "$$" "$TOKEN" >"$DRIVER_PIDF_TMP" +mv -f "$DRIVER_PIDF_TMP" "$DRIVER_PIDF" +prepare_token="$(tr -d '[:space:]' < "$PREPF" 2>/dev/null || true)" +[ "$prepare_token" = "$TOKEN" ] || exit 130 +rm -f "$PIDF" "$EXITF" +: >"$LOG" +prepare_token="$(tr -d '[:space:]' < "$PREPF" 2>/dev/null || true)" +[ "$prepare_token" = "$TOKEN" ] || exit 130 +nohup bash "$BODY" "$TOKEN" >"$LOG" 2>&1 < /dev/null & +body_pid=$! +printf '%s\n%s\n' "$body_pid" "$TOKEN" >"$PIDF_TMP" +mv -f "$PIDF_TMP" "$PIDF" +body_pid= +rm -f "$PREPF" "$DRIVER_PIDF" +trap - EXIT HUP INT TERM +exit 0 +"#, + dir = shell_quote_posix(dir), + body = shell_quote_posix(body_path), + token = shell_quote_posix(install_token), + ) +} + +#[allow(clippy::too_many_arguments)] +fn stage_install_command( + archive_path: &str, + body_path: &str, + script_path: &str, + log_path: &str, + pid_path: &str, + driver_pid_path: &str, + prepare_path: &str, + exit_path: &str, + install_token: &str, +) -> String { + format!( + "chmod 600 {archive} \ + && chmod 700 {body} {script} \ + && rm -f {pid} {driver_pid} {exit} \ + && : > {log} && chmod 600 {log} \ + && printf '%s\\n' {token} > {prepare} && chmod 600 {prepare}", + archive = shell_quote_posix(archive_path), + body = shell_quote_posix(body_path), + script = shell_quote_posix(script_path), + pid = shell_quote_posix(pid_path), + driver_pid = shell_quote_posix(driver_pid_path), + exit = shell_quote_posix(exit_path), + log = shell_quote_posix(log_path), + prepare = shell_quote_posix(prepare_path), + token = shell_quote_posix(install_token), + ) +} + +fn install_poll_script(cursor: u64) -> String { + format!( + r#" +D="$HOME/{INSTALL_STATE_DIR}" +LOG="$D/{INSTALL_STEM}.log" +PIDF="$D/{INSTALL_STEM}.pid" +DRIVER_PIDF="$D/{INSTALL_STEM}.driver.pid" +PREPF="$D/{INSTALL_STEM}.preparing" +EXITF="$D/{INSTALL_STEM}.exit" +BODY="$D/{INSTALL_STEM}-body.sh" +DRIVER="$D/{INSTALL_STEM}.sh" +process_command() {{ + p="$1" + case "$p" in ''|*[!0-9]*) return 1 ;; esac + kill -0 "$p" 2>/dev/null || return 1 + ps -ww -p "$p" -o command= 2>/dev/null +}} +installer_matches() {{ + p="$1" + token="$2" + [ -n "$token" ] || return 1 + command="$(process_command "$p")" || return 1 + case "$command" in + *"$BODY"*"$token"*) return 0 ;; + *) return 1 ;; + esac +}} +driver_matches() {{ + p="$1" + token="$2" + [ -n "$token" ] || return 1 + command="$(process_command "$p")" || return 1 + case "$command" in + *"$DRIVER"*"$token"*) return 0 ;; + *) return 1 ;; + esac +}} +running=0 +if [ -f "$PIDF" ]; then + pid="$(sed -n '1p' "$PIDF" | tr -d '[:space:]')" + token="$(sed -n '2p' "$PIDF" | tr -d '[:space:]')" + if installer_matches "$pid" "$token"; then + running=1 + else + rm -f "$PIDF" + fi +fi +preparing=0 +if [ -f "$PREPF" ]; then + driver_live=0 + prepare_token="$(tr -d '[:space:]' < "$PREPF" 2>/dev/null || true)" + if [ -f "$DRIVER_PIDF" ]; then + driver_pid="$(sed -n '1p' "$DRIVER_PIDF" | tr -d '[:space:]')" + driver_token="$(sed -n '2p' "$DRIVER_PIDF" | tr -d '[:space:]')" + if [ "$driver_token" = "$prepare_token" ] && driver_matches "$driver_pid" "$driver_token"; then + driver_live=1 + else + rm -f "$DRIVER_PIDF" + fi + fi + if [ "$driver_live" = "1" ]; then + preparing=1 + else + now="$(date +%s 2>/dev/null || true)" + mtime="$(stat -c %Y "$PREPF" 2>/dev/null || stat -f %m "$PREPF" 2>/dev/null || true)" + if [ -n "$now" ] && [ -n "$mtime" ] && [ $((now - mtime)) -lt {grace} ]; then + preparing=1 + fi + fi +fi +size=0 +if [ -f "$LOG" ]; then size="$(wc -c < "$LOG" | tr -d ' ')"; fi +marker=0 +if [ -f "$LOG" ] && grep -q {marker} "$LOG"; then marker=1; fi +exit_recorded=0 +exit_code= +if [ -f "$EXITF" ]; then + exit_recorded=1 + exit_code="$(tr -d '[:space:]' < "$EXITF")" +fi +printf 'running=%s\n' "$running" +printf 'preparing=%s\n' "$preparing" +printf 'size=%s\n' "$size" +printf 'marker=%s\n' "$marker" +printf 'exit_recorded=%s\n' "$exit_recorded" +printf 'exit_code=%s\n' "$exit_code" +printf '%s\n' '---' +from={from} +if [ "$from" -gt "$size" ]; then from=1; fi +if [ -f "$LOG" ]; then tail -c +"$from" "$LOG"; fi +"#, + grace = INSTALL_PREPARE_GRACE_SECONDS, + marker = shell_quote_posix(INSTALL_DONE_MARKER), + from = cursor.saturating_add(1), + ) +} + +fn install_cancel_script() -> String { + format!( + r#" +set +e +D="$HOME/{INSTALL_STATE_DIR}" +LOG="$D/{INSTALL_STEM}.log" +PIDF="$D/{INSTALL_STEM}.pid" +DRIVER_PIDF="$D/{INSTALL_STEM}.driver.pid" +PREPF="$D/{INSTALL_STEM}.preparing" +EXITF="$D/{INSTALL_STEM}.exit" +BODY="$D/{INSTALL_STEM}-body.sh" +DRIVER="$D/{INSTALL_STEM}.sh" +active=0 +[ -f "$PREPF" ] && active=1 +# Invalidate a driver that has not reached its guarded spawn point yet. +rm -f "$PREPF" +installer_matches() {{ + p="$1" + token="$2" + case "$p" in ''|*[!0-9]*) return 1 ;; esac + [ -n "$token" ] || return 1 + kill -0 "$p" 2>/dev/null || return 1 + command="$(ps -ww -p "$p" -o command= 2>/dev/null)" || return 1 + case "$command" in + *"$BODY"*"$token"*) return 0 ;; + *) return 1 ;; + esac +}} +driver_matches() {{ + p="$1" + token="$2" + case "$p" in ''|*[!0-9]*) return 1 ;; esac + [ -n "$token" ] || return 1 + kill -0 "$p" 2>/dev/null || return 1 + command="$(ps -ww -p "$p" -o command= 2>/dev/null)" || return 1 + case "$command" in + *"$DRIVER"*"$token"*) return 0 ;; + *) return 1 ;; + esac +}} +kill_tree() {{ + p="$1" + sig="$2" + case "$p" in ''|*[!0-9]*) return 0 ;; esac + for child in $(pgrep -P "$p" 2>/dev/null); do kill_tree "$child" "$sig"; done + kill "-$sig" "$p" 2>/dev/null || true +}} +if [ -f "$DRIVER_PIDF" ]; then + driver_pid="$(sed -n '1p' "$DRIVER_PIDF" | tr -d '[:space:]')" + driver_token="$(sed -n '2p' "$DRIVER_PIDF" | tr -d '[:space:]')" + if driver_matches "$driver_pid" "$driver_token"; then + active=1 + kill_tree "$driver_pid" TERM + sleep 1 + if driver_matches "$driver_pid" "$driver_token"; then + kill_tree "$driver_pid" KILL + fi + fi +fi +if [ -f "$PIDF" ]; then + pid="$(sed -n '1p' "$PIDF" | tr -d '[:space:]')" + token="$(sed -n '2p' "$PIDF" | tr -d '[:space:]')" + if installer_matches "$pid" "$token"; then + active=1 + kill_tree "$pid" TERM + sleep 1 + if installer_matches "$pid" "$token"; then kill_tree "$pid" KILL; fi + fi +fi +rm -f "$PIDF" "$DRIVER_PIDF" "$PREPF" +if [ "$active" = "1" ]; then + printf '130\n' >"$EXITF" + printf '\nBitFun CLI installation cancelled by client.\n' >>"$LOG" +fi +exit 0 +"# + ) +} + +fn to_unix_script(script: &str) -> String { + script.replace("\r\n", "\n") +} + +fn split_metadata_output(stdout: &str) -> (&str, &str) { + if let Some((head, output)) = stdout.split_once("---\r\n") { + return (head, output); + } + if let Some((head, output)) = stdout.split_once("---\n") { + return (head, output); + } + let mut offset = 0usize; + for line in stdout.split_inclusive('\n') { + if line.trim_end_matches(['\r', '\n']) == "---" { + return (&stdout[..offset], &stdout[offset + line.len()..]); + } + offset += line.len(); + } + (stdout, "") +} + +async fn exec_ok(manager: &SSHConnectionManager, connection_id: &str, command: &str) -> Result<()> { + let result = manager + .execute_command_with_options( + connection_id, + command, + SSHCommandOptions { + timeout_ms: Some(COMMAND_TIMEOUT_MS), + cancellation_token: None, + }, + ) + .await?; + ensure_command_completed(&result, "remote setup")?; + if result.exit_code != 0 { + return Err(remote_command_error( + "remote setup", + result.exit_code, + &result.stdout, + &result.stderr, + )); + } + Ok(()) +} + +fn ensure_command_completed( + result: &super::types::SSHCommandResult, + operation: &str, +) -> Result<()> { + if result.timed_out { + return Err(anyhow!("{operation} timed out")); + } + if result.interrupted { + return Err(anyhow!("{operation} was cancelled")); + } + Ok(()) +} + +fn remote_command_error( + operation: &str, + exit_code: i32, + stdout: &str, + stderr: &str, +) -> anyhow::Error { + let detail = if stderr.trim().is_empty() { + bounded_detail(stdout) + } else { + bounded_detail(stderr) + }; + anyhow!("{operation} failed (exit {exit_code}): {detail}") +} + +fn bounded_detail(value: &str) -> String { + value.trim().chars().take(500).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_install_scripts_are_lf_only_and_never_use_sudo() { + let body = install_body_script( + "/home/user/.bitfun/dispatch/install", + "/home/user/.bitfun/dispatch/install/archive.tar.gz", + "1.2.3", + ); + let driver = install_driver_script( + "/home/user/.bitfun/dispatch/install", + "/home/user/.bitfun/dispatch/install/install-cli-body.sh", + "bitfun-install-test-token", + ); + for (name, script) in [("body", body), ("driver", driver)] { + let script = to_unix_script(&script); + assert!(!script.contains('\r'), "{name} must be LF-only"); + assert!( + !script.contains("sudo"), + "{name} must never modify privileged paths" + ); + assert!(!script.contains("/usr/")); + } + } + + #[cfg(unix)] + #[test] + fn generated_install_scripts_parse_as_bash() { + for script in [ + install_body_script( + "/home/user/.bitfun/dispatch/install", + "/home/user/.bitfun/dispatch/install/archive.tar.gz", + "1.2.3", + ), + install_driver_script( + "/home/user/.bitfun/dispatch/install", + "/home/user/.bitfun/dispatch/install/install-cli-body.sh", + "bitfun-install-test-token", + ), + install_poll_script(17), + install_cancel_script(), + ] { + let output = std::process::Command::new("bash") + .args(["-n", "-c", &to_unix_script(&script)]) + .output() + .expect("parse generated shell"); + assert!( + output.status.success(), + "generated shell is invalid:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } + } + + #[cfg(unix)] + #[test] + fn stale_installer_pid_never_signals_an_unrelated_process() { + let temp = tempfile::tempdir().expect("temp dir"); + let state_dir = temp.path().join(INSTALL_STATE_DIR); + std::fs::create_dir_all(&state_dir).expect("install state dir"); + let pid_path = state_dir.join(format!("{INSTALL_STEM}.pid")); + let mut unrelated = std::process::Command::new("sleep") + .arg("30") + .spawn() + .expect("spawn unrelated process"); + + std::fs::write( + &pid_path, + format!("{}\nstale-install-token\n", unrelated.id()), + ) + .expect("stale pid marker"); + let poll = std::process::Command::new("bash") + .args(["-c", &install_poll_script(0)]) + .env("HOME", temp.path()) + .output() + .expect("poll stale installer"); + assert!(poll.status.success()); + assert!( + String::from_utf8_lossy(&poll.stdout).contains("running=0"), + "a reused PID must not be reported as the installer" + ); + assert!(!pid_path.exists(), "poll must clear the stale marker"); + + std::fs::write( + &pid_path, + format!("{}\nstale-install-token\n", unrelated.id()), + ) + .expect("restore stale pid marker"); + std::fs::write( + state_dir.join(format!("{INSTALL_STEM}.driver.pid")), + format!("{}\nstale-driver-token\n", unrelated.id()), + ) + .expect("stale driver pid marker"); + std::fs::write( + state_dir.join(format!("{INSTALL_STEM}.preparing")), + "stale-driver-token\n", + ) + .expect("stale prepare marker"); + let cancel = std::process::Command::new("bash") + .args(["-c", &install_cancel_script()]) + .env("HOME", temp.path()) + .output() + .expect("cancel stale installer"); + assert!(cancel.status.success()); + assert!( + unrelated + .try_wait() + .expect("inspect unrelated process") + .is_none(), + "cancellation must never signal a process that does not match the installer identity" + ); + assert!(!pid_path.exists(), "cancel must clear the stale marker"); + + unrelated.kill().expect("stop unrelated process"); + unrelated.wait().expect("reap unrelated process"); + } + + #[cfg(unix)] + #[test] + fn matching_installer_identity_is_cancelled() { + let temp = tempfile::tempdir().expect("temp dir"); + let state_dir = temp.path().join(INSTALL_STATE_DIR); + std::fs::create_dir_all(&state_dir).expect("install state dir"); + let body_path = state_dir.join(format!("{INSTALL_STEM}-body.sh")); + std::fs::write(&body_path, "sleep 30\n").expect("installer body"); + let token = "bitfun-install-test-token"; + let mut installer = std::process::Command::new("bash") + .arg(&body_path) + .arg(token) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn installer"); + std::fs::write( + state_dir.join(format!("{INSTALL_STEM}.pid")), + format!("{}\n{token}\n", installer.id()), + ) + .expect("installer pid marker"); + + let cancel = std::process::Command::new("bash") + .args(["-c", &install_cancel_script()]) + .env("HOME", temp.path()) + .output() + .expect("cancel installer"); + assert!(cancel.status.success()); + let stopped = installer + .try_wait() + .expect("inspect installer process") + .is_some(); + if !stopped { + installer.kill().expect("cleanup installer process"); + installer.wait().expect("reap installer process"); + } + assert!( + stopped, + "the exact PID/token/body identity must be cancelled" + ); + } + + #[cfg(unix)] + #[test] + fn cancelled_prepare_token_prevents_the_driver_from_spawning() { + let temp = tempfile::tempdir().expect("temp dir"); + let state_dir = temp.path().join(INSTALL_STATE_DIR); + std::fs::create_dir_all(&state_dir).expect("install state dir"); + let body_path = state_dir.join(format!("{INSTALL_STEM}-body.sh")); + let sentinel = temp.path().join("installer-started"); + std::fs::write( + &body_path, + format!("touch {}\n", shell_quote_posix(&sentinel.to_string_lossy())), + ) + .expect("installer body"); + let token = "bitfun-install-cancelled-before-driver"; + let driver = install_driver_script( + &state_dir.to_string_lossy(), + &body_path.to_string_lossy(), + token, + ); + + let output = std::process::Command::new("bash") + .args(["-c", &driver, "install-driver", token]) + .output() + .expect("run invalidated driver"); + assert_eq!(output.status.code(), Some(130)); + assert!( + !sentinel.exists(), + "a driver whose prepare token was removed must not start the body" + ); + assert!( + !state_dir + .join(format!("{INSTALL_STEM}.driver.pid")) + .exists(), + "the invalidated driver must clean its identity marker" + ); + } + + #[cfg(unix)] + #[test] + fn dispatch_command_round_trips_quoted_paths_and_removes_request() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp.path().join("bin with space"); + std::fs::create_dir_all(&bin_dir).expect("bin dir"); + let cli = bin_dir.join("bitfun's"); + std::fs::write(&cli, "#!/bin/sh\ncat\n").expect("fake CLI"); + let mut permissions = std::fs::metadata(&cli).unwrap().permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&cli, permissions).unwrap(); + + let request = temp.path().join("request with ' quote.json"); + let payload = r#"{"prompt":"$(touch should-not-run); it's literal"}"#; + std::fs::write(&request, payload).expect("request"); + let command = + dispatch_command(&cli.to_string_lossy(), "submit", &request.to_string_lossy()); + assert!( + !command.contains(payload), + "request JSON must never be embedded in the shell command" + ); + let output = std::process::Command::new("bash") + .args(["-c", &command]) + .output() + .expect("run quoted dispatch command"); + assert!( + output.status.success(), + "quoted command failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), payload); + assert!(!request.exists(), "EXIT trap must remove the request"); + assert!(!temp.path().join("should-not-run").exists()); + } + + #[test] + fn release_target_accepts_phase_one_unix_architectures() { + assert_eq!( + release_target("Linux", "amd64").unwrap(), + "x86_64-unknown-linux-gnu" + ); + assert_eq!( + release_target("Darwin", "arm64").unwrap(), + "aarch64-apple-darwin" + ); + assert!(release_target("Windows", "x86_64").is_err()); + } + + #[test] + fn probe_prefers_the_managed_cli_over_an_incompatible_path_copy() { + let script = probe_remote_target_script(); + let managed = script + .find(r#"[ -x "$HOME/.local/bin/bitfun" ]"#) + .expect("managed CLI check"); + let path_lookup = script.find("command -v bitfun").expect("PATH fallback"); + assert!(managed < path_lookup); + } + + #[test] + fn poll_metadata_split_accepts_lf_and_crlf() { + let (head, output) = split_metadata_output("running=1\n---\nhello\n"); + assert_eq!(head, "running=1\n"); + assert_eq!(output, "hello\n"); + let (head, output) = split_metadata_output("running=1\r\n---\r\nhello\r\n"); + assert_eq!(head, "running=1\r\n"); + assert_eq!(output, "hello\r\n"); + } + + #[test] + fn checksum_parser_used_by_dispatch_rejects_malformed_sidecars() { + assert!(crate::remote_ssh::release_verify::parse_sha256( + "not-a-checksum", + "archive.tar.gz" + ) + .is_err()); + } + + #[test] + fn streaming_archive_limit_is_enforced_before_appending_a_chunk() { + let mut archive = vec![1, 2, 3]; + extend_bounded_archive(&mut archive, &[4], 4).unwrap(); + assert_eq!(archive, vec![1, 2, 3, 4]); + assert!(extend_bounded_archive(&mut archive, &[5], 4).is_err()); + assert_eq!( + archive, + vec![1, 2, 3, 4], + "the over-limit chunk must never be buffered" + ); + } + + #[test] + fn installation_is_bound_to_the_exact_confirmed_release() { + let confirmed = DispatchCliRelease { + version: "1.2.3".to_string(), + target: "aarch64-apple-darwin".to_string(), + url: "https://example.test/bitfun.tar.gz".to_string(), + sha256: "a".repeat(64), + }; + ensure_confirmed_release(&confirmed, &confirmed).expect("exact asset"); + + let mut changed = confirmed.clone(); + changed.sha256 = "b".repeat(64); + assert!(ensure_confirmed_release(&changed, &confirmed).is_err()); + } + + #[test] + fn incompatible_dispatch_protocols_require_an_upgrade() { + let capabilities = REQUIRED_DISPATCH_CAPABILITIES; + let compatible = serde_json::json!({ + "protocolVersion": 1, + "capabilities": capabilities, + }); + assert!(dispatch_protocol_is_compatible(&compatible)); + + let old = serde_json::json!({ + "protocolVersion": 0, + "capabilities": capabilities, + }); + assert!(!dispatch_protocol_is_compatible(&old)); + + let missing = serde_json::json!({ + "protocolVersion": 1, + "capabilities": ["persistent_jobs", "cursor_events"], + }); + assert!(!dispatch_protocol_is_compatible(&missing)); + + let reject_only = serde_json::json!({ + "protocolVersion": 1, + "capabilities": [ + "persistent_jobs", + "cursor_events", + "detached_worker", + "workspace_serialization", + "frontend_event_projection", + "approval_reject_and_report" + ], + }); + validate_dispatch_protocol(&reject_only, Some("reject-and-report")) + .expect("selected policy is supported"); + assert!(validate_dispatch_protocol(&reject_only, Some("auto")).is_err()); + } +} diff --git a/src/crates/services/services-integrations/src/remote_ssh/mod.rs b/src/crates/services/services-integrations/src/remote_ssh/mod.rs index f9c5f3042f..cb3f2bbbed 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/mod.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/mod.rs @@ -17,12 +17,16 @@ mod workspace_services; #[cfg(not(feature = "remote-ssh-concrete"))] mod disabled; #[cfg(feature = "remote-ssh-concrete")] +pub mod dispatch_ssh; +#[cfg(feature = "remote-ssh-concrete")] pub mod manager; #[cfg(feature = "remote-ssh-concrete")] mod password_vault; #[cfg(feature = "remote-ssh-concrete")] pub mod relay_deploy; #[cfg(feature = "remote-ssh-concrete")] +mod release_verify; +#[cfg(feature = "remote-ssh-concrete")] mod remote_exec; #[cfg(feature = "remote-ssh-concrete")] mod remote_exec_runtime_port; @@ -44,7 +48,7 @@ pub use workspace_services::{remote_workspace_services, RemoteWorkspaceFs, Remot #[cfg(not(feature = "remote-ssh-concrete"))] pub use disabled::{ - get_global_remote_exec_process_manager, KnownHostEntry, PTYSession, PortForward, + dispatch_ssh, get_global_remote_exec_process_manager, KnownHostEntry, PTYSession, PortForward, PortForwardDirection, PortForwardManager, RemoteExecCommandRequest, RemoteExecCommandResponse, RemoteExecControlAction, RemoteExecControlOrigin, RemoteExecControlRequest, RemoteExecError, RemoteExecProcessLifecycleEvent, RemoteExecProcessLifecycleStatus, RemoteExecProcessManager, diff --git a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs index 75d4e8dba6..52bf777337 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs @@ -27,6 +27,9 @@ use serde::{Deserialize, Serialize}; use std::time::Duration; use super::manager::SSHConnectionManager; +use super::release_verify::{release_pubkey, release_tag_for_version, verify_signed_checksum}; +#[cfg(test)] +use super::release_verify::{verify_minisign, RELEASE_PUBKEY}; use super::remote_git::shell_quote_posix; /// Default public relay port, matching `src/apps/relay-server/docker-compose.yml`. @@ -72,10 +75,6 @@ const STALL_WINDOW_SECONDS: u64 = 30; /// target dir and Docker layers). Checked before the build rather than /// discovered as an opaque compiler failure part-way through. const SOURCE_BUILD_FREE_KB: u64 = 6 * 1024 * 1024; -/// Trust root for published relay archives, injected at build time from the -/// same `TAURI_UPDATER_PUBKEY` the Desktop updater uses. Absent in local and -/// fork builds, which then fall back to the cross-origin checksum. -const RELEASE_PUBKEY: Option<&str> = option_env!("BITFUN_RELEASE_PUBKEY"); /// Targets a published relay archive exists for. const RELEASE_TARGETS: [&str; 2] = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]; /// Canonical China-mirror helper (shared with `src/apps/relay-server/deploy.sh`). @@ -102,14 +101,6 @@ const TASK_DONE_MARKER: &str = "RELAY_TASK_DONE"; /// alive driver (an open sudo password prompt, say) is never bounded by this. const PREPARE_GRACE_SECONDS: u64 = 90; -fn release_tag_for_version(version: &str) -> String { - if version.contains("-nightly.") { - "nightly".to_string() - } else { - format!("v{}", version.split('+').next().unwrap_or(version)) - } -} - /// Long-running remote operations that run detached and are polled. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -527,9 +518,7 @@ pub async fn start_task( fn strip_cr_command(path: &str) -> String { let src = shell_quote_posix(path); let tmp = shell_quote_posix(&format!("{path}.lf")); - format!( - "{{ tr -d '\\r' < {src} > {tmp} && mv {tmp} {src}; }} || {{ rm -f {tmp}; false; }}" - ) + format!("{{ tr -d '\\r' < {src} > {tmp} && mv {tmp} {src}; }} || {{ rm -f {tmp}; false; }}") } /// Prepare uploaded scripts for the PTY: normalize line endings, make them @@ -1647,9 +1636,11 @@ export BITFUN_STALL_SECONDS="{stall_seconds}" /// /// Best effort by design — an empty map simply leaves the remote on the /// cross-origin checksum path. -async fn verified_release_checksums(release_tag: &str) -> std::collections::HashMap { +async fn verified_release_checksums( + release_tag: &str, +) -> std::collections::HashMap { let mut verified = std::collections::HashMap::new(); - let Some(pubkey) = RELEASE_PUBKEY.filter(|key| !key.trim().is_empty()) else { + let Some(pubkey) = release_pubkey() else { return verified; }; let Ok(client) = reqwest::Client::builder() @@ -1661,26 +1652,28 @@ async fn verified_release_checksums(release_tag: &str) -> std::collections::Hash }; for target in RELEASE_TARGETS { - let checksum_url = - format!("{RELEASE_BASE}/download/{release_tag}/bitfun-relay-server-{target}.tar.gz.sha256"); + let checksum_url = format!( + "{RELEASE_BASE}/download/{release_tag}/bitfun-relay-server-{target}.tar.gz.sha256" + ); let Some(checksum) = fetch_text(&client, &checksum_url).await else { continue; }; let Some(signature) = fetch_text(&client, &format!("{checksum_url}.sig")).await else { continue; }; - if let Err(error) = verify_minisign(checksum.as_bytes(), &signature, pubkey) { - log::warn!("Relay checksum signature for {target} did not verify: {error}"); - continue; - } - let Some(hash) = checksum - .split_whitespace() - .next() - .filter(|value| value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())) - else { - continue; + let hash = match verify_signed_checksum( + &checksum, + &signature, + pubkey, + &format!("bitfun-relay-server-{target}.tar.gz"), + ) { + Ok(hash) => hash, + Err(error) => { + log::warn!("Relay checksum signature for {target} did not verify: {error}"); + continue; + } }; - verified.insert(target.to_string(), hash.to_ascii_lowercase()); + verified.insert(target.to_string(), hash); } verified } @@ -1698,25 +1691,6 @@ async fn fetch_text(client: &reqwest::Client, url: &str) -> Option { .ok() } -/// Verify a Tauri-format `.sig` (base64 of a minisign signature file) over -/// `data`, using the base64-wrapped public key. -fn verify_minisign(data: &[u8], signature_b64: &str, pubkey_b64: &str) -> Result<()> { - use base64::Engine as _; - let decode = |value: &str, what: &str| -> Result { - let bytes = base64::engine::general_purpose::STANDARD - .decode(value.trim().as_bytes()) - .map_err(|error| anyhow!("decode {what}: {error}"))?; - String::from_utf8(bytes).map_err(|error| anyhow!("decode {what} as UTF-8: {error}")) - }; - let public_key = minisign_verify::PublicKey::decode(&decode(pubkey_b64, "public key")?) - .map_err(|error| anyhow!("invalid release public key: {error}"))?; - let signature = minisign_verify::Signature::decode(&decode(signature_b64, "signature")?) - .map_err(|error| anyhow!("invalid release signature: {error}"))?; - public_key - .verify(data, &signature, false) - .map_err(|error| anyhow!("signature does not match: {error}")) -} - /// Shell assignments exporting the verified hashes the remote script consumes. fn verified_checksum_exports(verified: &std::collections::HashMap) -> String { let mut exports = String::new(); @@ -1828,9 +1802,8 @@ mod tests { install_docker_body_script, interactive_driver_script, parse_preflight, prepare_helpers_bash, release_binary_deploy_bash, release_tag_for_version, split_poll_stdout, stage_scripts_command, sync_source_bash, to_unix_script, - verified_checksum_exports, - verified_release_checksums, verify_minisign, DockerAccessMode, RelayTaskStatus, - RELAY_MIRROR_SH, RELAY_RELEASE_DOWNLOAD_SH, RELEASE_PUBKEY, + verified_checksum_exports, verified_release_checksums, verify_minisign, DockerAccessMode, + RelayTaskStatus, RELAY_MIRROR_SH, RELAY_RELEASE_DOWNLOAD_SH, RELEASE_PUBKEY, }; #[test] @@ -1938,7 +1911,11 @@ mod tests { "{path} must be LF-only on the host" ); let mode = std::fs::metadata(path).expect("stat").permissions().mode(); - assert_eq!(mode & 0o777, 0o700, "{path} must stay owner-only executable"); + assert_eq!( + mode & 0o777, + 0o700, + "{path} must stay owner-only executable" + ); } // The rewrite must not leave its scratch file behind. assert!(!std::path::Path::new(&format!("{script_path}.lf")).exists()); @@ -1948,7 +1925,10 @@ mod tests { !command.contains('\r'), "the CR strip must not depend on a raw CR surviving transport" ); - assert!(!std::path::Path::new(&pid_path).exists(), "stale pid cleared"); + assert!( + !std::path::Path::new(&pid_path).exists(), + "stale pid cleared" + ); assert!( !std::path::Path::new(&driver_pid_path).exists(), "stale driver pid cleared" @@ -1972,7 +1952,10 @@ mod tests { ); for (name, script) in [ - ("deploy driver", interactive_driver_script("deploy", "deploy")), + ( + "deploy driver", + interactive_driver_script("deploy", "deploy"), + ), ( "install driver", interactive_driver_script("install-docker", "install"), diff --git a/src/crates/services/services-integrations/src/remote_ssh/release_verify.rs b/src/crates/services/services-integrations/src/remote_ssh/release_verify.rs new file mode 100644 index 0000000000..6b4610e83d --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_ssh/release_verify.rs @@ -0,0 +1,136 @@ +//! Shared verification primitives for BitFun release assets. +//! +//! Remote installers must use the same build-time trust root as the CLI +//! self-updater. A SHA256 sidecar detects corruption, while the minisign +//! signature establishes who published that checksum or archive. + +use anyhow::{anyhow, Context, Result}; +use sha2::{Digest, Sha256}; + +/// Ed25519 (minisign) public key injected into official builds from the same +/// `TAURI_UPDATER_PUBKEY` used by the Desktop and CLI updaters. +/// +/// Fork and development builds normally have no key. Callers that install +/// executable code must use [`require_release_pubkey`] and fail closed. +pub(crate) const RELEASE_PUBKEY: Option<&str> = option_env!("BITFUN_RELEASE_PUBKEY"); + +pub(crate) fn release_pubkey() -> Option<&'static str> { + RELEASE_PUBKEY.filter(|key| !key.trim().is_empty()) +} + +pub(crate) fn require_release_pubkey() -> Result<&'static str> { + release_pubkey().ok_or_else(|| { + anyhow!("this build has no BitFun release signing key; refusing to install executable code") + }) +} + +pub(crate) fn release_tag_for_version(version: &str) -> String { + if version.contains("-nightly.") { + "nightly".to_string() + } else { + format!("v{}", version.split('+').next().unwrap_or(version)) + } +} + +/// Parse the first digest from a conventional ` ` SHA256 +/// sidecar. The filename is diagnostic only; release sidecars may use either +/// one or two spaces before it. +pub(crate) fn parse_sha256(checksum_text: &str, filename: &str) -> Result { + checksum_text + .split_whitespace() + .next() + .filter(|value| value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())) + .map(str::to_ascii_lowercase) + .ok_or_else(|| anyhow!("invalid SHA256 file for {filename}")) +} + +pub(crate) fn verify_sha256(data: &[u8], expected: &str, filename: &str) -> Result<()> { + let expected = expected.trim(); + if expected.len() != 64 || !expected.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(anyhow!("invalid expected SHA256 for {filename}")); + } + let actual = format!("{:x}", Sha256::digest(data)); + if !actual.eq_ignore_ascii_case(expected) { + return Err(anyhow!("SHA256 mismatch for {filename}")); + } + Ok(()) +} + +/// Verify a Tauri-format `.sig` (base64 of a complete minisign signature file) +/// using the likewise base64-wrapped public key. +pub(crate) fn verify_minisign(data: &[u8], signature_b64: &str, pubkey_b64: &str) -> Result<()> { + use base64::Engine as _; + + let decode = |value: &str, what: &str| -> Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(value.trim().as_bytes()) + .with_context(|| format!("decode {what}"))?; + String::from_utf8(bytes).with_context(|| format!("decode {what} as UTF-8")) + }; + + let public_key = minisign_verify::PublicKey::decode(&decode(pubkey_b64, "release public key")?) + .map_err(|error| anyhow!("invalid release public key: {error}"))?; + let signature = + minisign_verify::Signature::decode(&decode(signature_b64, "release signature")?) + .map_err(|error| anyhow!("invalid release signature: {error}"))?; + public_key + .verify(data, &signature, false) + .map_err(|error| anyhow!("release signature does not match the signed data: {error}")) +} + +/// Verify the minisign signature over a checksum sidecar, then return its +/// normalized digest. This is useful when the eventual target host has only a +/// SHA256 implementation and no copy of the BitFun trust root. +pub(crate) fn verify_signed_checksum( + checksum_text: &str, + signature_b64: &str, + pubkey_b64: &str, + filename: &str, +) -> Result { + verify_minisign(checksum_text.as_bytes(), signature_b64, pubkey_b64)?; + parse_sha256(checksum_text, filename) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fixture produced with the real `minisign` CLI, then wrapped the way + /// Tauri wraps keys and signatures. + const FIXTURE_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgRTNFMDg3NENFQzFDMjJDMwpSV1RESWh6c1RJZmc0MXcyR3dpZWkwek5ES2FMWW05ZFFWcEVXTlEvVWxweXQybWJTMkpFMVUyTQo="; + const FIXTURE_SIGNATURE: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVUREloenNUSWZnNDBMTitwb25aT3RCVy9VYmJtNWhkR1poM0lCb3IwUDBKaVZmZmM1cFJaNlZSNUpaSzNUUm1yWWpYMXFLQ2svWTdZUDhHdkRZT3YvanVoZlpnZmhyWEFRPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg0OTUxOTM1CWZpbGU6YXJjaGl2ZS50YXIuZ3oJaGFzaGVkCjhWL21EUVAwZGdlZXVNU1lxWlpsOWdFSGUwOTJQTk9yRG1BMUV6ZHNQOUlEYkcyT1dneTFsQ1puUDBJaFIwQnJpMFBCeENRcUdDR2dpb0l0UGtSMUN3PT0K"; + const FIXTURE_DATA: &[u8] = b"hello-bitfun\n"; + + #[test] + fn minisign_wire_format_accepts_authentic_data_and_rejects_tampering() { + verify_minisign(FIXTURE_DATA, FIXTURE_SIGNATURE, FIXTURE_PUBKEY) + .expect("fixture signature must verify"); + assert!(verify_minisign(b"tampered\n", FIXTURE_SIGNATURE, FIXTURE_PUBKEY).is_err()); + assert!(verify_minisign(FIXTURE_DATA, "bm90LWEtc2ln", FIXTURE_PUBKEY).is_err()); + } + + #[test] + fn sha256_sidecar_requires_a_well_formed_digest_and_matching_bytes() { + let digest = format!("{:x}", Sha256::digest(FIXTURE_DATA)); + assert_eq!( + parse_sha256( + &format!("{digest} bitfun-cli.tar.gz\n"), + "bitfun-cli.tar.gz" + ) + .unwrap(), + digest + ); + verify_sha256(FIXTURE_DATA, &digest, "bitfun-cli.tar.gz").unwrap(); + assert!(verify_sha256(FIXTURE_DATA, &"0".repeat(64), "bitfun-cli.tar.gz").is_err()); + assert!(parse_sha256("not-a-digest", "bitfun-cli.tar.gz").is_err()); + } + + #[test] + fn release_tags_keep_stable_and_nightly_channels() { + assert_eq!(release_tag_for_version("0.2.13"), "v0.2.13"); + assert_eq!( + release_tag_for_version("0.2.14-nightly.20260724+abc123"), + "nightly" + ); + } +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 09c85de0fd..78f1d11e0b 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -261,6 +261,39 @@ white-space: nowrap; } + &__inline-item-dispatch-badge { + flex: 0 1 auto; + display: inline-flex; + align-items: center; + min-width: 0; + max-width: 104px; + height: 14px; + padding: 0 5px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-accent-400) 38%, var(--border-subtle)); + border-radius: 999px; + background: color-mix(in srgb, var(--color-accent-400) 12%, var(--element-bg-soft)); + color: color-mix(in srgb, var(--color-accent-400) 76%, var(--color-text-primary)); + font-size: var(--font-size-xxs); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + + &[data-state='failed'], + &[data-state='cancelled'], + &[data-state='unreachable'] { + border-color: var(--color-error-border); + background: var(--color-error-bg); + color: var(--color-error); + } + + &[data-state='succeeded'] { + border-color: var(--color-success-border); + background: var(--color-success-bg); + color: var(--color-success); + } + } + &__inline-item-review-badge { flex: 0 0 auto; display: inline-flex; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 194d5a7e08..13a772d904 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -54,6 +54,12 @@ import { confirmWarning } from '@/component-library/components/ConfirmDialog/con import { notificationService } from '@/shared/notification-system'; import { copyTextToClipboard } from '@/shared/utils/textSelection'; import { scheduleAfterStartupPaint, scheduleAfterStartupSignal } from '@/shared/utils/startupTaskScheduling'; +import { + isNonLocalDispatchTarget, + type DispatchJobState, +} from '@/features/dispatch/types'; +import { useDispatchJobStore } from '@/features/dispatch/dispatchJobStore'; +import { resolveDispatchNavPresentation } from '@/features/dispatch/dispatchNavPresentation'; import { SESSION_METADATA_DEFERRED_FALLBACK_MS, SESSION_METADATA_DEFERRED_FRAME_COUNT, @@ -183,6 +189,7 @@ const SessionsSection: React.FC = ({ flowChatStore.getState() ); const backgroundSubagentActivities = useBackgroundSubagentActivityStore(state => state.activities); + const dispatchTransportByJobId = useDispatchJobStore(state => state.transportByJobId); const [editingSessionId, setEditingSessionId] = useState(null); const [editingTitle, setEditingTitle] = useState(''); const [expandLevel, setExpandLevel] = useState<0 | 1 | 2>(0); @@ -1068,7 +1075,49 @@ const SessionsSection: React.FC = ({ const parentTurnIndex = relationship.origin?.parentTurnIndex; const trimmedAssistant = assistantLabel?.trim() ?? ''; const showAssistantInTooltip = trimmedAssistant.length > 0; - const showRichTooltip = showAssistantInTooltip || isChildSession || showBackgroundSubagentActivity; + const dispatchTarget = session.config.dispatchTarget; + const isDispatched = isNonLocalDispatchTarget(dispatchTarget); + const dispatchTargetLabel = + dispatchTarget?.kind === 'ssh' || dispatchTarget?.kind === 'device' + ? dispatchTarget.displayName + : ''; + const dispatchState = session.config.dispatchJobState ?? 'submitting'; + const dispatchStateLabel = { + submitting: t('nav.sessions.dispatchStates.submitting'), + submission_unknown: t('nav.sessions.dispatchStates.submission_unknown'), + queued: t('nav.sessions.dispatchStates.queued'), + running: t('nav.sessions.dispatchStates.running'), + succeeded: t('nav.sessions.dispatchStates.succeeded'), + failed: t('nav.sessions.dispatchStates.failed'), + cancelled: t('nav.sessions.dispatchStates.cancelled'), + } satisfies Record; + const dispatchTransport = session.config.dispatchJobId + ? dispatchTransportByJobId[session.config.dispatchJobId] + : undefined; + const dispatchTransportError = + dispatchTransport?.lastTransportError?.trim() + || t('nav.sessions.dispatchTransportErrorFallback'); + const dispatchPresentation = isDispatched + ? resolveDispatchNavPresentation({ + targetLabel: dispatchTargetLabel, + state: dispatchState, + reachability: dispatchTransport?.reachability, + runningSummary: t('nav.sessions.dispatchRunningOn', { + target: dispatchTargetLabel, + state: dispatchStateLabel[dispatchState], + }), + unreachableLabel: t('nav.sessions.dispatchUnreachable'), + unreachableSummary: t('nav.sessions.dispatchUnreachableDetails', { + target: dispatchTargetLabel, + error: dispatchTransportError, + }), + }) + : null; + const showRichTooltip = + showAssistantInTooltip || + isChildSession || + showBackgroundSubagentActivity || + isDispatched; const tooltipContent = showRichTooltip ? (
{sessionTitle}
@@ -1089,6 +1138,11 @@ const SessionsSection: React.FC = ({ })}
) : null} + {isDispatched ? ( +
+ {dispatchPresentation?.summary} +
+ ) : null} {showBackgroundSubagentActivity && backgroundSubagentActivity ? ( <>
@@ -1254,6 +1308,15 @@ const SessionsSection: React.FC = ({ {isChildSession ? ( {childSessionBadge} ) : null} + {isDispatched ? ( + + {dispatchPresentation?.badgeLabel} + + ) : null} {attentionKind === 'ask_user' || attentionKind === 'tool_confirm' ? ( {attentionKind === 'ask_user' diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss new file mode 100644 index 0000000000..01a9438bd9 --- /dev/null +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss @@ -0,0 +1,208 @@ +@use '../../component-library/styles/tokens' as *; + +.dispatch-install-dialog { + display: flex; + flex-direction: column; + gap: $size-gap-3; + padding: $size-gap-3; + color: var(--color-text-primary); + + &__field { + display: flex; + flex-direction: column; + gap: $size-gap-1; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 600; + } + + &__field-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: $size-gap-2; + align-items: center; + } + + &__checks { + display: grid; + gap: $size-gap-1; + + > div { + display: grid; + grid-template-columns: minmax(96px, auto) minmax(0, 1fr); + gap: $size-gap-2; + padding: $size-gap-2; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-sm; + background: var(--element-bg-soft); + font-size: var(--font-size-xs); + + > span { + color: var(--color-text-muted); + } + + > strong { + min-width: 0; + overflow-wrap: anywhere; + font-weight: 500; + } + + &[data-state='ok'] > strong { + color: var(--color-success); + } + + &[data-state='blocked'] > strong { + color: var(--color-warning); + } + } + } + + &__install-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: $size-gap-2; + padding: $size-gap-3; + border: 1px solid var(--color-warning-border); + border-radius: $size-radius-base; + background: var(--color-warning-bg); + + > div:first-child { + display: flex; + flex-direction: column; + gap: $size-gap-1; + + > span { + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + } + } + + dl { + display: grid; + gap: $size-gap-1; + width: 100%; + margin: 0; + font-size: var(--font-size-xxs); + } + + dl > div { + display: grid; + grid-template-columns: 78px minmax(0, 1fr); + gap: $size-gap-2; + } + + dt { + color: var(--color-text-muted); + } + + dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + font-family: var(--font-family-mono); + } + } + + &__output { + box-sizing: border-box; + max-height: 140px; + margin: 0; + padding: $size-gap-2; + overflow: auto; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-sm; + background: var(--color-bg-secondary); + color: var(--color-text-secondary); + font: var(--font-size-xxs)/1.45 var(--font-family-mono); + white-space: pre-wrap; + } + + &__approval { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $size-gap-2; + min-width: 0; + margin: 0; + padding: $size-gap-2; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + + legend { + padding: 0 $size-gap-1; + font-size: var(--font-size-xs); + font-weight: 600; + } + + > .dispatch-install-dialog__approval-hint { + grid-column: 1 / -1; + color: var(--color-text-muted); + font-size: var(--font-size-xxs); + } + + > button { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: $size-gap-2; + min-width: 0; + padding: $size-gap-2; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-primary); + text-align: left; + cursor: pointer; + + &:hover, + &:focus-visible, + &[data-selected='true'] { + border-color: var(--color-accent-500); + background: color-mix(in srgb, var(--color-accent-500) 9%, transparent); + outline: none; + } + + > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + } + + strong { + font-size: var(--font-size-xs); + font-weight: 600; + } + + small { + color: var(--color-text-muted); + font-size: var(--font-size-xxs); + line-height: 1.35; + } + } + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: $size-gap-2; + } + + &__spin { + animation: dispatch-install-spin 1s linear infinite; + } +} + +@media (max-width: 620px) { + .dispatch-install-dialog { + &__approval { + grid-template-columns: 1fr; + } + } +} + +@keyframes dispatch-install-spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx new file mode 100644 index 0000000000..11371d5cf0 --- /dev/null +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -0,0 +1,268 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DispatchInstallDialog } from './DispatchInstallDialog'; +import type { DispatchInstallStart } from './types'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + probeTarget: vi.fn(), + installCliStart: vi.fn(), + installCliPoll: vi.fn(), + installCliCancel: vi.fn(), + confirmWarning: vi.fn(), + modalOnClose: null as (() => void) | null, + modalLifecycleProps: null as { + closeOnOverlayClick?: boolean; + showCloseButton?: boolean; + } | null, +})); + +vi.mock('./dispatchApi', () => ({ + dispatchApi: { + probeTarget: mocks.probeTarget, + installCliStart: mocks.installCliStart, + installCliPoll: mocks.installCliPoll, + installCliCancel: mocks.installCliCancel, + }, +})); + +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@/component-library', () => ({ + Alert: ({ message }: { message: string }) =>
{message}
, + Button: ({ + children, + disabled, + onClick, + }: React.PropsWithChildren<{ + disabled?: boolean; + onClick?: React.MouseEventHandler; + }>) => ( + + ), + Input: ({ + disabled, + onChange, + onKeyDown, + placeholder, + value, + }: { + disabled?: boolean; + onChange?: React.ChangeEventHandler; + onKeyDown?: React.KeyboardEventHandler; + placeholder?: string; + value?: string; + }) => ( + + ), + Modal: ({ + children, + closeOnOverlayClick, + isOpen, + onClose, + showCloseButton, + }: React.PropsWithChildren<{ + closeOnOverlayClick?: boolean; + isOpen: boolean; + onClose: () => void; + showCloseButton?: boolean; + }>) => { + mocks.modalOnClose = onClose; + mocks.modalLifecycleProps = { + closeOnOverlayClick, + showCloseButton, + }; + return isOpen ?
{children}
: null; + }, + confirmWarning: mocks.confirmWarning, +})); + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, reject, resolve }; +} + +describe('DispatchInstallDialog installation lifecycle', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.modalOnClose = null; + mocks.modalLifecycleProps = null; + mocks.probeTarget.mockResolvedValue({ + cliInstalled: false, + os: 'linux', + arch: 'x86_64', + installSupported: true, + release: { + version: '1.2.3', + target: 'x86_64-unknown-linux-gnu', + url: 'https://example.test/bitfun', + sha256: 'abc123', + }, + }); + mocks.confirmWarning.mockResolvedValue(true); + mocks.installCliCancel.mockResolvedValue(undefined); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('cancels a late installer acknowledgement after the dialog closes during start', async () => { + const start = createDeferred(); + mocks.installCliStart.mockReturnValue(start.promise); + const onClose = vi.fn(); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + const installButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.installConfirm')); + expect(installButton).toBeDefined(); + + await act(async () => { + installButton?.click(); + await Promise.resolve(); + }); + expect(mocks.installCliStart).toHaveBeenCalledTimes(1); + expect(mocks.modalLifecycleProps).toEqual({ + closeOnOverlayClick: false, + showCloseButton: false, + }); + const cancelButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent === 'dispatch.cancel'); + expect(cancelButton?.disabled).toBe(true); + + await act(async () => { + mocks.modalOnClose?.(); + await Promise.resolve(); + }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(mocks.installCliCancel).toHaveBeenCalledTimes(1); + + await act(async () => { + start.resolve({ + scriptPath: '/tmp/install-bitfun.sh', + version: '1.2.3', + target: 'x86_64-unknown-linux-gnu', + url: 'https://example.test/bitfun', + sha256: 'abc123', + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.installCliCancel).toHaveBeenCalledTimes(2); + expect(mocks.installCliCancel).toHaveBeenLastCalledWith('ssh-1'); + expect(mocks.installCliPoll).not.toHaveBeenCalled(); + expect(container.querySelector('pre')).toBeNull(); + }); + + it('cancels an acknowledged installer when the parent closes the dialog during polling', async () => { + const poll = createDeferred<{ + cursor: number; + output: string; + status: 'running'; + }>(); + mocks.installCliStart.mockResolvedValue({ + scriptPath: '/tmp/install-bitfun.sh', + version: '1.2.3', + target: 'x86_64-unknown-linux-gnu', + url: 'https://example.test/bitfun', + sha256: 'abc123', + }); + mocks.installCliPoll.mockReturnValue(poll.promise); + const target = { + kind: 'ssh' as const, + connectionId: 'ssh-1', + displayName: 'build-host', + }; + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + const installButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.installConfirm')); + await act(async () => { + installButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mocks.installCliStart).toHaveBeenCalledTimes(1); + expect(mocks.installCliPoll).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + expect(mocks.installCliCancel).toHaveBeenCalledTimes(1); + expect(mocks.installCliCancel).toHaveBeenCalledWith('ssh-1'); + + await act(async () => { + poll.resolve({ + cursor: 1, + output: 'still running', + status: 'running', + }); + await Promise.resolve(); + }); + expect(mocks.installCliPoll).toHaveBeenCalledTimes(1); + expect(mocks.installCliCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx new file mode 100644 index 0000000000..18522ce535 --- /dev/null +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx @@ -0,0 +1,460 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Alert, + Button, + Input, + Modal, + confirmWarning, +} from '@/component-library'; +import { useI18n } from '@/infrastructure/i18n'; +import { createLogger } from '@/shared/utils/logger'; +import { Check, Loader2, RefreshCw, ShieldAlert, ShieldCheck } from 'lucide-react'; +import { dispatchApi } from './dispatchApi'; +import type { + DispatchApprovalPolicy, + DispatchInstallStart, + DispatchSelection, + DispatchSshProbe, + DispatchTargetOption, +} from './types'; +import { + BASE_DISPATCH_CAPABILITIES, + DISPATCH_PROTOCOL_VERSION, + isDispatchWorkspaceReady, +} from './dispatchPreflight'; +import './DispatchInstallDialog.scss'; + +const log = createLogger('DispatchInstallDialog'); +const INSTALL_POLL_INTERVAL_MS = 1200; + +interface ActiveInstall { + connectionId: string; + generation: number; + phase: 'starting' | 'polling'; +} + +function approvalCapability(policy: DispatchApprovalPolicy | null): string | null { + if (policy === 'auto') return 'approval_auto'; + if (policy === 'reject-and-report') return 'approval_reject_and_report'; + return null; +} + +interface DispatchInstallDialogProps { + open: boolean; + target: DispatchTargetOption | null; + onClose: () => void; + onReady: (selection: DispatchSelection) => void; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export const DispatchInstallDialog: React.FC = ({ + open, + target, + onClose, + onReady, +}) => { + const { t } = useI18n('common'); + const [workspacePath, setWorkspacePath] = useState(''); + const [approvalPolicy, setApprovalPolicy] = useState(null); + const [probe, setProbe] = useState(null); + const [probing, setProbing] = useState(false); + const [installing, setInstalling] = useState(false); + const [installStart, setInstallStart] = useState(null); + const [installOutput, setInstallOutput] = useState(''); + const [error, setError] = useState(null); + const generationRef = useRef(0); + const activeInstallRef = useRef(null); + const workspacePathRef = useRef(workspacePath); + workspacePathRef.current = workspacePath; + + const connectionId = target?.connectionId?.trim() ?? ''; + + const runProbe = useCallback(async (pathOverride?: string) => { + if (!connectionId) return; + const path = (pathOverride ?? workspacePathRef.current).trim(); + const generation = ++generationRef.current; + setProbing(true); + setError(null); + try { + const result = await dispatchApi.probeTarget({ + kind: 'ssh', + connectionId, + workspacePath: path, + }); + if (generation === generationRef.current) { + setProbe(result); + } + } catch (nextError) { + if (generation === generationRef.current) { + setProbe(null); + setError(errorMessage(nextError)); + } + } finally { + if (generation === generationRef.current) { + setProbing(false); + } + } + }, [connectionId]); + + useEffect(() => { + if (!open || !connectionId) return; + const initialPath = target?.defaultWorkspace?.trim() ?? ''; + setWorkspacePath(initialPath); + setApprovalPolicy(null); + setProbe(null); + setInstallStart(null); + setInstallOutput(''); + setInstalling(false); + setError(null); + void runProbe(initialPath); + }, [connectionId, open, runProbe, target?.defaultWorkspace]); + + const clearActiveInstall = useCallback((generation: number) => { + if (activeInstallRef.current?.generation === generation) { + activeInstallRef.current = null; + } + }, []); + + const cancelActiveInstall = useCallback(() => { + const activeInstall = activeInstallRef.current; + if (!activeInstall) return; + activeInstallRef.current = null; + void dispatchApi.installCliCancel(activeInstall.connectionId).catch(nextError => { + log.warn('Failed to cancel SSH CLI installation', { error: nextError }); + }); + }, []); + + const invalidateInstallLifecycle = useCallback(() => { + generationRef.current += 1; + cancelActiveInstall(); + }, [cancelActiveInstall]); + + useEffect(() => { + if (!open || !connectionId) return; + return invalidateInstallLifecycle; + }, [connectionId, invalidateInstallLifecycle, open]); + + const pollInstallation = useCallback(async (generation: number) => { + if (!connectionId) return; + let cursor = 0; + if ( + generation !== generationRef.current || + activeInstallRef.current?.generation !== generation + ) { + return; + } + activeInstallRef.current = { + connectionId, + generation, + phase: 'polling', + }; + setInstalling(true); + try { + while (generation === generationRef.current) { + const result = await dispatchApi.installCliPoll(connectionId, cursor); + if (generation !== generationRef.current) return; + cursor = result.cursor; + if (result.output) { + setInstallOutput(previous => previous + result.output); + } + if (result.status === 'succeeded') { + clearActiveInstall(generation); + setInstalling(false); + await runProbe(); + return; + } + if (result.status === 'failed') { + clearActiveInstall(generation); + setInstalling(false); + setError(t('dispatch.installFailed')); + return; + } + await new Promise(resolve => window.setTimeout(resolve, INSTALL_POLL_INTERVAL_MS)); + } + clearActiveInstall(generation); + } catch (nextError) { + if (generation === generationRef.current) { + clearActiveInstall(generation); + setInstalling(false); + setError(errorMessage(nextError)); + } + } + }, [clearActiveInstall, connectionId, runProbe, t]); + + const startInstallation = useCallback(async () => { + if (!connectionId || !probe?.release) return; + const release = probe.release; + const generation = ++generationRef.current; + const confirmed = await confirmWarning( + t('dispatch.installConfirmTitle'), + t('dispatch.installConfirmMessage', { + version: release.version, + url: release.url, + sha256: release.sha256, + }), + { + confirmText: t('dispatch.installConfirm'), + cancelText: t('dispatch.cancel'), + }, + ); + if (!confirmed || generation !== generationRef.current) return; + + setError(null); + setInstallOutput(''); + setInstalling(true); + activeInstallRef.current = { + connectionId, + generation, + phase: 'starting', + }; + try { + const started = await dispatchApi.installCliStart(connectionId, release); + if (generation !== generationRef.current) { + clearActiveInstall(generation); + // Closing while the start request is in flight may race with a first + // cancel that reaches the target before the installer exists. Cancel + // again after the late start acknowledgement to avoid an orphan. + await dispatchApi.installCliCancel(connectionId).catch(nextError => { + log.warn('Failed to cancel stale SSH CLI installation', { error: nextError }); + }); + return; + } + setInstallStart(started); + void pollInstallation(generation); + } catch (nextError) { + clearActiveInstall(generation); + if (generation === generationRef.current) { + setInstalling(false); + setError(errorMessage(nextError)); + } + } + }, [clearActiveInstall, connectionId, pollInstallation, probe?.release, t]); + + const close = useCallback(() => { + invalidateInstallLifecycle(); + setInstalling(false); + onClose(); + }, [invalidateInstallLifecycle, onClose]); + + const protocol = probe?.protocol; + const workspace = protocol?.workspace; + const selectedApprovalCapability = approvalCapability(approvalPolicy); + const requiredCapabilities = [ + ...BASE_DISPATCH_CAPABILITIES, + ...(selectedApprovalCapability ? [selectedApprovalCapability] : []), + ]; + const missingCapabilities = protocol + ? requiredCapabilities.filter(capability => !protocol.capabilities.includes(capability)) + : requiredCapabilities; + const protocolCompatible = + protocol?.protocolVersion === DISPATCH_PROTOCOL_VERSION && + missingCapabilities.length === 0; + const cliReady = + !!probe?.cliInstalled && + !!protocol && + !probe.protocolError && + protocolCompatible; + const workspaceReady = isDispatchWorkspaceReady(workspacePath, workspace); + const modelReady = protocol?.modelConfigured === true; + const ready = cliReady && workspaceReady && modelReady && approvalPolicy !== null; + + const confirmTarget = () => { + if (!target || !connectionId || !approvalPolicy || !ready) return; + const normalizedPath = workspacePath.trim(); + onReady({ + request: { + kind: 'ssh', + connectionId, + workspacePath: normalizedPath, + }, + target: { + kind: 'ssh', + connectionId, + workspacePath: normalizedPath, + displayName: target.displayName, + }, + approvalPolicy, + }); + }; + + return ( + +
+ {error ? ( + setError(null)} /> + ) : null} + + + + {probe ? ( +
+
+ {t('dispatch.cliStatus')} + + {cliReady + ? t('dispatch.cliReady', { version: protocol?.cliVersion }) + : probe.cliInstalled && protocol + ? t('dispatch.cliIncompatible', { + details: protocol.protocolVersion !== DISPATCH_PROTOCOL_VERSION + ? t('dispatch.protocolVersionMismatch', { + expected: DISPATCH_PROTOCOL_VERSION, + actual: protocol.protocolVersion, + }) + : missingCapabilities.join(', '), + }) + : t('dispatch.cliMissing')} + +
+ {workspacePath.trim() ? ( +
+ {t('dispatch.workspaceStatus')} + + {workspaceReady + ? workspace?.isGitRepository + ? t('dispatch.workspaceGit', { + branch: workspace.branch || t('dispatch.unknownBranch'), + dirty: workspace.dirty ? t('dispatch.dirty') : t('dispatch.clean'), + }) + : t('dispatch.workspaceDirectory') + : t('dispatch.workspaceMissing')} + +
+ ) : null} + {workspaceReady && workspace?.isGitRepository && + (typeof workspace.ahead === 'number' || typeof workspace.behind === 'number') ? ( +
+ {t('dispatch.upstreamStatus')} + + {t('dispatch.upstreamCounts', { + ahead: workspace.ahead ?? 0, + behind: workspace.behind ?? 0, + })} + +
+ ) : null} +
+ {t('dispatch.modelStatus')} + + {modelReady + ? t('dispatch.modelReady', { model: protocol?.defaultModel || t('dispatch.modelAutomatic') }) + : protocol?.modelDiagnostic || t('dispatch.modelMissing')} + +
+
+ ) : null} + + {!cliReady && probe?.release ? ( +
+
+ {t('dispatch.installRequired')} + {t('dispatch.installDescription')} +
+
+
{t('dispatch.version')}
{probe.release.version}
+
{t('dispatch.downloadUrl')}
{probe.release.url}
+
SHA256
{probe.release.sha256}
+
+ +
+ ) : null} + + {probe?.installError ? ( + + ) : null} + + {installStart || installOutput ? ( +
+            {installOutput || t('dispatch.installWaiting')}
+          
+ ) : null} + +
+ {t('dispatch.approvalTitle')} + + {t('dispatch.approvalHint')} + + + +
+ +
+ + +
+
+
+ ); +}; diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts new file mode 100644 index 0000000000..fbc311a76d --- /dev/null +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts @@ -0,0 +1,677 @@ +/** + * @vitest-environment jsdom + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + dispatchEventId, + installDispatchJobObserver, + projectDispatchAgentEvent, + requestDispatchJobRefresh, +} from './DispatchJobObserver'; +import { dispatchJobStore } from './dispatchJobStore'; +import type { DispatchEvent, DispatchStatusResponse } from './types'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { stateMachineManager } from '@/flow_chat/state-machine'; +import { + SessionExecutionEvent, + SessionExecutionState, +} from '@/flow_chat/state-machine/types'; +import { scheduleModelResponseStatus } from '@/flow_chat/services/flow-chat-manager/RuntimeStatusModule'; + +const mocks = vi.hoisted(() => ({ + listJobs: vi.fn(), + status: vi.fn(), + dispatchExternal: vi.fn(), +})); + +vi.mock('./dispatchApi', () => ({ + dispatchApi: { + listJobs: mocks.listJobs, + status: mocks.status, + }, +})); + +vi.mock('@/infrastructure/peer-device/peerModeFlag', () => ({ + isPeerDeviceModeActive: () => false, +})); + +vi.mock('@/flow_chat/services/AgenticEventListener', () => ({ + agenticEventListener: { + dispatchExternal: mocks.dispatchExternal, + }, +})); + +function registerRunningJob(): void { + dispatchJobStore.getState().registerJob({ + jobId: 'job-1', + sessionId: 'session-1', + targetRequest: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/repo', + }, + target: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/repo', + displayName: 'build-host', + }, + sourceWorkspacePath: '/source', + title: 'Dispatch test', + agentType: 'agentic', + approvalPolicy: 'reject-and-report', + cursor: 0, + state: 'running', + appliedEventIds: [], + createdAt: 1, + updatedAt: 1, + }); +} + +function createContext() { + const sessions = new Map([ + ['session-1', { + sessionId: 'session-1', + config: { + dispatchTarget: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/repo', + displayName: 'build-host', + }, + dispatchJobId: 'job-1', + dispatchCursor: 0, + }, + }], + ]); + return { + currentWorkspacePath: '/source', + flowChatStore: { + getState: vi.fn(() => ({ sessions })), + addExternalSession: vi.fn(), + updateSessionDispatchTarget: vi.fn(), + applyDispatchSnapshot: vi.fn(( + sessionId: string, + snapshot: { cursor: number; state: string }, + ) => { + const session = sessions.get(sessionId); + session.config.dispatchCursor = snapshot.cursor; + session.config.dispatchJobState = snapshot.state; + return { applied: true, cursor: snapshot.cursor }; + }), + }, + eventBatcher: { + flushNow: vi.fn(), + }, + } as any; +} + +function createTerminalContext() { + const processingManager = { + clearSessionStatus: vi.fn(), + }; + return { + currentWorkspacePath: '/source', + flowChatStore, + processingManager, + eventBatcher: { + flushNow: vi.fn(), + getBufferSize: vi.fn(() => 0), + }, + pendingTurnCompletions: new Map(), + pendingHistoryLoads: new Map(), + contentBuffers: new Map([ + ['session-1', new Map([['round-1', 'partial']])], + ]), + activeTextItems: new Map([ + ['session-1', new Map([['round-1', 'text-1']])], + ]), + saveDebouncers: new Map(), + lastSaveTimestamps: new Map(), + lastSaveHashes: new Map(), + turnSaveInFlight: new Map(), + turnSavePending: new Set(), + runtimeStatusTimers: new Map(), + userCancelledSessionIds: new Set(), + handledTerminalTurnEvents: new Set(), + } as any; +} + +function installProcessingProjection(): void { + const session = { + sessionId: 'session-1', + title: 'Dispatch test', + dialogTurns: [{ + id: 'turn-1', + sessionId: 'session-1', + userMessage: { + id: 'user-1', + content: 'run task', + timestamp: 1, + }, + modelRounds: [{ + id: 'round-1', + index: 0, + items: [ + { + id: 'text-1', + type: 'text', + content: 'partial output', + status: 'streaming', + isStreaming: true, + timestamp: 1, + }, + { + id: 'tool-1', + type: 'tool', + toolName: 'Bash', + toolCall: { + id: 'tool-1', + input: {}, + }, + status: 'running', + requiresConfirmation: false, + isParamsStreaming: true, + startTime: 1, + timestamp: 1, + }, + ], + isStreaming: true, + isComplete: false, + status: 'streaming', + startTime: 1, + }], + status: 'processing', + startTime: 1, + }], + status: 'idle', + config: { + agentType: 'agentic', + dispatchTargetRequest: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/repo', + }, + dispatchTarget: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/repo', + displayName: 'build-host', + }, + dispatchJobId: 'job-1', + dispatchApprovalPolicy: 'reject-and-report', + dispatchJobState: 'running', + dispatchCursor: 0, + }, + createdAt: 1, + lastActiveAt: 1, + error: null, + historyState: 'ready', + mode: 'agentic', + workspacePath: '/source', + projectWorkspacePath: '/source', + sessionKind: 'normal', + }; + flowChatStore.setState(() => ({ + sessions: new Map([['session-1', session as any]]), + activeSessionId: 'session-1', + })); +} + +function status( + overrides: Partial = {}, +): DispatchStatusResponse { + return { + state: 'running', + cursor: 0, + events: [], + pendingPermissions: [], + cursorReset: false, + ...overrides, + }; +} + +describe('DispatchJobObserver', () => { + beforeEach(() => { + vi.useFakeTimers(); + dispatchJobStore.getState().clear(); + flowChatStore.setState(() => ({ + sessions: new Map(), + activeSessionId: null, + })); + stateMachineManager.clear(); + mocks.listJobs.mockReset().mockResolvedValue([]); + mocks.status.mockReset(); + mocks.dispatchExternal.mockReset().mockReturnValue(true); + }); + + afterEach(() => { + stateMachineManager.clear(); + flowChatStore.setState(() => ({ + sessions: new Map(), + activeSessionId: null, + })); + vi.useRealTimers(); + }); + + it('projects raw target events into the existing frontend event contract', () => { + const projected = projectDispatchAgentEvent({ + type: 'agentEvent', + timestamp: '2026-07-28T00:00:00Z', + event: { + id: 'event-1', + event: { + type: 'TextChunk', + session_id: 'session-1', + turn_id: 'turn-1', + round_id: 'round-1', + text: 'hello', + }, + }, + }); + + expect(projected).toEqual({ + eventName: 'agentic://text-chunk', + envelopeId: 'event-1', + payload: { + sessionId: 'session-1', + turnId: 'turn-1', + roundId: 'round-1', + text: 'hello', + }, + }); + }); + + it('ignores subagent links until child dispatch projections have an owner', () => { + expect(projectDispatchAgentEvent({ + type: 'agentEvent', + timestamp: '2026-07-28T00:00:00Z', + event: { + id: 'event-child', + frontendEventName: 'agentic://subagent-session-linked', + frontendPayload: { + parentSessionId: 'session-1', + childSessionId: 'child-1', + }, + event: { + type: 'SubagentSessionLinked', + parent_session_id: 'session-1', + child_session_id: 'child-1', + }, + }, + })).toBeNull(); + }); + + it('keeps the cursor until an event applies, then deduplicates it on replay', async () => { + registerRunningJob(); + const event: DispatchEvent = { + type: 'agentEvent', + timestamp: '2026-07-28T00:00:00Z', + event: { + id: 'event-1', + frontendEventName: 'agentic://text-chunk', + frontendPayload: { + sessionId: 'session-1', + turnId: 'turn-1', + roundId: 'round-1', + text: 'hello', + }, + }, + }; + mocks.status.mockResolvedValue(status({ + cursor: 25, + events: [event], + })); + mocks.dispatchExternal + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + const cleanup = installDispatchJobObserver(createContext()); + + await vi.advanceTimersByTimeAsync(0); + expect(dispatchJobStore.getState().jobs['job-1'].cursor).toBe(0); + expect(mocks.dispatchExternal).toHaveBeenCalledTimes(1); + + requestDispatchJobRefresh('job-1'); + await vi.advanceTimersByTimeAsync(0); + expect(dispatchJobStore.getState().jobs['job-1'].cursor).toBe(25); + expect(dispatchJobStore.getState().hasAppliedEvent('job-1', dispatchEventId(event))).toBe(true); + expect(mocks.dispatchExternal).toHaveBeenCalledTimes(2); + + requestDispatchJobRefresh('job-1'); + await vi.advanceTimersByTimeAsync(0); + expect(mocks.dispatchExternal).toHaveBeenCalledTimes(2); + cleanup(); + }); + + it('drains every terminal page before it stops polling', async () => { + registerRunningJob(); + mocks.status + .mockResolvedValueOnce(status({ + state: 'succeeded', + cursor: 12, + events: [{ + type: 'jobState', + timestamp: '2026-07-28T00:00:00Z', + state: 'succeeded', + }], + })) + .mockResolvedValueOnce(status({ + state: 'succeeded', + cursor: 12, + events: [], + })); + const cleanup = installDispatchJobObserver(createContext()); + + await vi.advanceTimersByTimeAsync(0); + expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ + state: 'succeeded', + cursor: 12, + terminalDrained: false, + }); + + requestDispatchJobRefresh('job-1'); + await vi.advanceTimersByTimeAsync(0); + expect(dispatchJobStore.getState().jobs['job-1'].terminalDrained).toBe(true); + + requestDispatchJobRefresh('job-1'); + await vi.advanceTimersByTimeAsync(0); + expect(mocks.status).toHaveBeenCalledTimes(2); + cleanup(); + }); + + it('does not query a target job before submit acknowledgement', async () => { + registerRunningJob(); + dispatchJobStore.getState().updateProgress('job-1', { state: 'submitting' }); + // updateProgress cannot regress running to submitting, so register the true pre-ack shape. + dispatchJobStore.getState().registerJob({ + ...dispatchJobStore.getState().jobs['job-1'], + state: 'submitting', + }); + const cleanup = installDispatchJobObserver(createContext()); + + await vi.advanceTimersByTimeAsync(0); + expect(mocks.status).not.toHaveBeenCalled(); + cleanup(); + }); + + it('reports transient target unreachability without terminalizing the job and clears it after recovery', async () => { + registerRunningJob(); + mocks.status + .mockRejectedValueOnce(new Error('SSH target is offline')) + .mockResolvedValueOnce(status({ state: 'running' })); + const cleanup = installDispatchJobObserver(createContext()); + + await vi.advanceTimersByTimeAsync(0); + expect(dispatchJobStore.getState().jobs['job-1'].state).toBe('running'); + expect(dispatchJobStore.getState().transportByJobId['job-1']).toEqual({ + reachability: 'unreachable', + lastTransportError: 'SSH target is offline', + }); + + requestDispatchJobRefresh('job-1'); + await vi.advanceTimersByTimeAsync(0); + expect(dispatchJobStore.getState().jobs['job-1'].state).toBe('running'); + expect(dispatchJobStore.getState().transportByJobId['job-1']).toEqual({ + reachability: 'reachable', + lastTransportError: undefined, + }); + cleanup(); + }); + + it('settles cancellation after the terminal log drains without a dialog-turn-cancelled event', async () => { + registerRunningJob(); + installProcessingProjection(); + const context = createTerminalContext(); + await stateMachineManager.transition( + 'session-1', + SessionExecutionEvent.START, + { taskId: 'session-1', dialogTurnId: 'turn-1' }, + ); + mocks.status + .mockResolvedValueOnce(status({ + state: 'cancelled', + cursor: 12, + events: [{ + type: 'jobState', + timestamp: '2026-07-29T00:00:00Z', + state: 'cancelled', + }], + })) + .mockResolvedValueOnce(status({ + state: 'cancelled', + cursor: 12, + events: [], + })); + const cleanup = installDispatchJobObserver(context); + + await vi.advanceTimersByTimeAsync(0); + expect(flowChatStore.getState().sessions.get('session-1')?.dialogTurns[0].status) + .toBe('processing'); + expect(stateMachineManager.getCurrentState('session-1')) + .toBe(SessionExecutionState.PROCESSING); + + requestDispatchJobRefresh('job-1'); + await vi.advanceTimersByTimeAsync(0); + const turn = flowChatStore.getState().sessions.get('session-1')?.dialogTurns[0]; + expect(turn).toMatchObject({ + status: 'cancelled', + modelRounds: [{ + status: 'cancelled', + isStreaming: false, + isComplete: true, + items: [ + { id: 'text-1', status: 'cancelled', isStreaming: false }, + { id: 'tool-1', status: 'cancelled', isParamsStreaming: false }, + ], + }], + }); + expect(stateMachineManager.getCurrentState('session-1')) + .toBe(SessionExecutionState.IDLE); + expect(context.processingManager.clearSessionStatus) + .toHaveBeenCalledWith('session-1'); + expect(mocks.dispatchExternal).not.toHaveBeenCalled(); + cleanup(); + }); + + it('cancels delayed runtime status rendering when a terminal snapshot drains', async () => { + registerRunningJob(); + installProcessingProjection(); + flowChatStore.updateDialogTurn('session-1', 'turn-1', turn => ({ + ...turn, + modelRounds: turn.modelRounds.map(round => ({ + ...round, + items: [{ + id: 'runtime-status-main-round-1', + type: 'text', + content: '\u200B', + timestamp: 1, + status: 'streaming', + isStreaming: true, + isMarkdown: false, + runtimeStatus: { + phase: 'waiting_model', + scope: 'main', + }, + }], + })), + })); + const context = createTerminalContext(); + scheduleModelResponseStatus( + context, + 'session-1', + 'turn-1', + 'round-1', + { delayMs: 1000 }, + ); + expect(context.runtimeStatusTimers.size).toBe(1); + mocks.status.mockResolvedValue(status({ + state: 'succeeded', + cursor: 0, + events: [], + })); + const cleanup = installDispatchJobObserver(context); + + await vi.advanceTimersByTimeAsync(0); + const settledItems = flowChatStore + .getState() + .sessions + .get('session-1')! + .dialogTurns[0] + .modelRounds[0] + .items; + expect(context.runtimeStatusTimers.size).toBe(0); + expect(settledItems).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(1000); + const itemsAfterTimerDeadline = flowChatStore + .getState() + .sessions + .get('session-1')! + .dialogTurns[0] + .modelRounds[0] + .items; + expect(itemsAfterTimerDeadline).toHaveLength(0); + expect(itemsAfterTimerDeadline.some(item => ( + item.type === 'text' && item.runtimeStatus + ))).toBe(false); + cleanup(); + }); + + it('safely settles a queued job that reaches terminal state before any turn event', async () => { + registerRunningJob(); + installProcessingProjection(); + const queuedSession = flowChatStore.getState().sessions.get('session-1')!; + flowChatStore.setState(state => ({ + ...state, + sessions: new Map(state.sessions).set('session-1', { + ...queuedSession, + dialogTurns: [], + }), + })); + const context = createTerminalContext(); + mocks.status.mockResolvedValue(status({ + state: 'cancelled', + cursor: 0, + events: [], + })); + const cleanup = installDispatchJobObserver(context); + + await vi.advanceTimersByTimeAsync(0); + const session = flowChatStore.getState().sessions.get('session-1'); + expect(session?.dialogTurns).toEqual([]); + expect(session?.config.dispatchJobState).toBe('cancelled'); + expect(stateMachineManager.getCurrentState('session-1')) + .toBe(SessionExecutionState.IDLE); + cleanup(); + }); + + it('does not duplicate convergence already owned by a terminal agent event', async () => { + registerRunningJob(); + installProcessingProjection(); + const session = flowChatStore.getState().sessions.get('session-1')!; + const terminalTurn = { + ...session.dialogTurns[0], + status: 'completed' as const, + success: true, + endTime: 5, + modelRounds: [], + }; + flowChatStore.setState(state => ({ + ...state, + sessions: new Map(state.sessions).set('session-1', { + ...session, + dialogTurns: [terminalTurn], + }), + })); + const context = createTerminalContext(); + context.handledTerminalTurnEvents.add('session-1:turn-1'); + mocks.status.mockResolvedValue(status({ + state: 'succeeded', + cursor: 0, + events: [], + })); + const cleanup = installDispatchJobObserver(context); + + await vi.advanceTimersByTimeAsync(0); + const appliedTurn = flowChatStore + .getState() + .sessions + .get('session-1')! + .dialogTurns[0]; + expect(appliedTurn).toBe(terminalTurn); + expect(context.processingManager.clearSessionStatus).not.toHaveBeenCalled(); + expect(stateMachineManager.getCurrentState('session-1')) + .toBe(SessionExecutionState.IDLE); + cleanup(); + }); + + it.each([ + { + jobState: 'failed' as const, + turnState: 'error', + itemState: 'error', + lastError: 'Remote execution failed', + }, + { + jobState: 'succeeded' as const, + turnState: 'completed', + itemState: 'completed', + lastError: undefined, + }, + ])('settles $jobState snapshots idempotently after terminal drain', async ({ + jobState, + turnState, + itemState, + lastError, + }) => { + registerRunningJob(); + installProcessingProjection(); + const context = createTerminalContext(); + await stateMachineManager.transition( + 'session-1', + SessionExecutionEvent.START, + { taskId: 'session-1', dialogTurnId: 'turn-1' }, + ); + mocks.status.mockResolvedValue(status({ + state: jobState, + cursor: 0, + events: [], + lastError, + })); + const cleanup = installDispatchJobObserver(context); + + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); + const firstSession = flowChatStore.getState().sessions.get('session-1')!; + const firstTurn = firstSession.dialogTurns[0]; + expect(firstTurn.status).toBe(turnState); + expect(firstTurn.modelRounds[0].items[0].status).toBe(itemState); + expect(stateMachineManager.getCurrentState('session-1')) + .toBe(SessionExecutionState.IDLE); + if (jobState === 'failed') { + expect(firstSession.error).toBe(lastError); + expect(firstTurn.error).toBe(lastError); + } + + const repeated = flowChatStore.applyDispatchSnapshot('session-1', { + jobId: 'job-1', + state: jobState, + cursor: 0, + expectedCursor: 0, + lastError, + terminalDrained: true, + }); + const repeatedTurn = flowChatStore + .getState() + .sessions + .get('session-1')! + .dialogTurns[0]; + expect(repeated).toEqual({ applied: true, cursor: 0 }); + expect(repeatedTurn).toBe(firstTurn); + expect(repeatedTurn.endTime).toBe(firstTurn.endTime); + expect(stateMachineManager.getCurrentState('session-1')) + .toBe(SessionExecutionState.IDLE); + cleanup(); + }); +}); diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts new file mode 100644 index 0000000000..3492adb2c8 --- /dev/null +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts @@ -0,0 +1,497 @@ +import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; +import { createLogger } from '@/shared/utils/logger'; +import { agenticEventListener } from '@/flow_chat/services/AgenticEventListener'; +import type { FlowChatContext } from '@/flow_chat/services/flow-chat-manager/types'; +import { clearRuntimeStatus } from '@/flow_chat/services/flow-chat-manager/RuntimeStatusModule'; +import { stateMachineManager } from '@/flow_chat/state-machine'; +import { + SessionExecutionEvent, + SessionExecutionState, +} from '@/flow_chat/state-machine/types'; +import { dispatchApi } from './dispatchApi'; +import { dispatchJobStore, type DispatchObserverJob } from './dispatchJobStore'; +import type { + DispatchAgentEventEnvelope, + DispatchEvent, + DispatchJobState, + DispatchStatusResponse, +} from './types'; +import { isDispatchJobTerminal } from './types'; + +const log = createLogger('DispatchJobObserver'); + +export const DISPATCH_JOB_POLL_INTERVAL_MS = 1800; + +type RefreshRequester = (jobId?: string) => void; +let installedRefreshRequester: RefreshRequester | null = null; + +export function requestDispatchJobRefresh(jobId?: string): void { + installedRefreshRequester?.(jobId); +} + +const RAW_EVENT_NAMES: Record = { + SessionCreated: 'agentic://session-created', + SessionDeleted: 'agentic://session-deleted', + SessionStateChanged: 'agentic://session-state-changed', + SessionTitleGenerated: 'session_title_generated', + ImageAnalysisStarted: 'agentic://image-analysis-started', + ImageAnalysisCompleted: 'agentic://image-analysis-completed', + DialogTurnStarted: 'agentic://dialog-turn-started', + // Phase one has no child-observer ownership. Ignoring this link prevents an + // unmarked child projection from being mistaken for a local session. + ModelRoundStarted: 'agentic://model-round-started', + ModelRoundCompleted: 'agentic://model-round-completed', + ModelRoundAttemptSuperseded: 'agentic://model-round-attempt-superseded', + TextChunk: 'agentic://text-chunk', + ThinkingChunk: 'agentic://text-chunk', + ToolEvent: 'agentic://tool-event', + DialogTurnCompleted: 'agentic://dialog-turn-completed', + DialogTurnFailed: 'agentic://dialog-turn-failed', + DialogTurnCancelled: 'agentic://dialog-turn-cancelled', + TokenUsageUpdated: 'agentic://token-usage-updated', + ContextCompressionStarted: 'agentic://context-compression-started', + ContextCompressionCompleted: 'agentic://context-compression-completed', + ContextCompressionFailed: 'agentic://context-compression-failed', + ThreadGoalUpdated: 'agentic://thread-goal-updated', + DeepReviewQueueStateChanged: 'agentic://deep-review-queue-state-changed', + SessionModelAutoMigrated: 'agentic://session-model-auto-migrated', + UserSteeringInjected: 'agentic://user-steering-injected', +}; + +function camelKey(key: string): string { + return key.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()); +} + +function camelize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(camelize); + } + if (!value || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value as Record) + .map(([key, nested]) => [camelKey(key), camelize(nested)]), + ); +} + +export function projectDispatchAgentEvent( + dispatchEvent: Extract, +): { eventName: string; payload: Record; envelopeId?: string } | null { + const outer = dispatchEvent as unknown as Record; + const envelope = dispatchEvent.event as DispatchAgentEventEnvelope; + const eventRecord = envelope?.event && typeof envelope.event === 'object' + ? envelope.event + : dispatchEvent.event; + const projectedName = + (typeof outer.frontendEventName === 'string' && outer.frontendEventName) + || (typeof envelope?.frontendEventName === 'string' && envelope.frontendEventName); + const projectedPayload = + (outer.frontendPayload && typeof outer.frontendPayload === 'object' + ? outer.frontendPayload + : undefined) + || (envelope?.frontendPayload && typeof envelope.frontendPayload === 'object' + ? envelope.frontendPayload + : undefined); + if (projectedName === 'agentic://subagent-session-linked') { + return null; + } + if (projectedName && projectedPayload) { + return { + eventName: projectedName, + payload: projectedPayload as Record, + envelopeId: typeof envelope?.id === 'string' ? envelope.id : undefined, + }; + } + + if (!eventRecord || typeof eventRecord !== 'object') { + return null; + } + const raw = eventRecord as Record; + const rawType = typeof raw.type === 'string' ? raw.type : ''; + const eventName = RAW_EVENT_NAMES[rawType]; + if (!eventName) { + return null; + } + const payload = camelize(raw) as Record; + delete payload.type; + if (rawType === 'ThinkingChunk') { + payload.text = payload.content; + payload.contentType = 'thinking'; + payload.isThinkingEnd = payload.isEnd; + delete payload.content; + delete payload.isEnd; + } + if (rawType === 'SessionTitleGenerated' && payload.timestamp === undefined) { + payload.timestamp = Date.now(); + } + return { + eventName, + payload, + envelopeId: typeof envelope?.id === 'string' ? envelope.id : undefined, + }; +} + +function hashText(value: string): string { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +} + +function transportErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function dispatchEventId(event: DispatchEvent): string { + if (event.type === 'agentEvent') { + const envelope = event.event as DispatchAgentEventEnvelope; + if (typeof envelope?.id === 'string' && envelope.id) { + return envelope.id; + } + } + return `${event.type}:${event.timestamp}:${hashText(JSON.stringify(event))}`; +} + +function ensureProjection(context: FlowChatContext, job: DispatchObserverJob): boolean { + const existing = context.flowChatStore.getState().sessions.get(job.sessionId); + if (existing) { + return true; + } + + // The persisted cursor represents a transcript that lived only in the old + // renderer process. Rebuild a fresh in-memory projection by replaying from + // byte zero; never skip straight to that cursor. + dispatchJobStore.getState().resetReplay(job.jobId); + const workspacePath = job.sourceWorkspacePath || context.currentWorkspacePath || undefined; + context.flowChatStore.addExternalSession( + job.sessionId, + job.title, + job.agentType, + workspacePath, + { + projectWorkspacePath: workspacePath, + workspaceId: job.sourceWorkspaceId, + }, + ); + context.flowChatStore.updateSessionDispatchTarget(job.sessionId, { + targetRequest: job.targetRequest, + target: job.target, + jobId: job.jobId, + approvalPolicy: job.approvalPolicy, + state: job.state, + cursor: 0, + }); + return context.flowChatStore.getState().sessions.has(job.sessionId); +} + +function applyEvent(context: FlowChatContext, event: DispatchEvent): boolean { + if (event.type !== 'agentEvent') { + return true; + } + const projected = projectDispatchAgentEvent(event); + if (!projected) { + log.debug('Ignoring unprojectable target agent event', { event }); + return true; + } + const applied = agenticEventListener.dispatchExternal( + projected.eventName, + projected.payload, + ); + if (!applied) { + return false; + } + context.eventBatcher.flushNow(); + return true; +} + +function isStreamingExecutionState(state: SessionExecutionState): boolean { + return ( + state === SessionExecutionState.PROCESSING || + state === SessionExecutionState.FINISHING + ); +} + +function reconcileDispatchTerminalRuntime( + context: FlowChatContext, + sessionId: string, + state: DispatchJobState, + lastError?: string, +): void { + if (!isDispatchJobTerminal(state)) return; + + const pendingCompletion = context.pendingTurnCompletions?.get(sessionId); + if (pendingCompletion?.timer) { + clearTimeout(pendingCompletion.timer); + } + context.pendingTurnCompletions?.delete(sessionId); + const runtimeStatusTimerPrefix = `${sessionId}:`; + for (const [key, timer] of context.runtimeStatusTimers?.entries() ?? []) { + if (!key.startsWith(runtimeStatusTimerPrefix)) { + continue; + } + clearTimeout(timer); + context.runtimeStatusTimers.delete(key); + } + const dialogTurns = context.flowChatStore + .getState() + .sessions + .get(sessionId) + ?.dialogTurns ?? []; + const lastTurn = dialogTurns[dialogTurns.length - 1]; + if (lastTurn) { + clearRuntimeStatus(context, sessionId, lastTurn.id); + } + context.activeTextItems?.get(sessionId)?.clear(); + context.contentBuffers?.get(sessionId)?.clear(); + context.processingManager?.clearSessionStatus(sessionId); + context.userCancelledSessionIds?.delete(sessionId); + + const settleStateMachine = async () => { + const currentState = stateMachineManager.getCurrentState(sessionId); + if (state === 'failed') { + if (isStreamingExecutionState(currentState)) { + await stateMachineManager.transition( + sessionId, + SessionExecutionEvent.ERROR_OCCURRED, + { error: lastError || 'Dispatched task failed' }, + ); + } + if ( + stateMachineManager.getCurrentState(sessionId) === + SessionExecutionState.ERROR + ) { + await stateMachineManager.transition( + sessionId, + SessionExecutionEvent.RESET, + ); + } + return; + } + + if (isStreamingExecutionState(currentState)) { + await stateMachineManager.transition( + sessionId, + SessionExecutionEvent.FINISHING_SETTLED, + ); + } + }; + + void settleStateMachine().catch(error => { + log.warn('Failed to settle dispatch terminal state machine', { + sessionId, + state, + error, + }); + }); +} + +async function refreshJob(context: FlowChatContext, requestedJobId: string): Promise { + let job = dispatchJobStore.getState().jobs[requestedJobId]; + if (!job) { + return; + } + // `submitting` is a local pre-ack state. There is no durable target job to + // query yet, and a failed submit intentionally remains retryable. + if (job.state === 'submitting') { + return; + } + const projectionExisted = context.flowChatStore.getState().sessions.has(job.sessionId); + if (!ensureProjection(context, job)) { + return; + } + if (projectionExisted && isDispatchJobTerminal(job.state) && job.terminalDrained) { + return; + } + if (!projectionExisted) { + job = dispatchJobStore.getState().jobs[requestedJobId] ?? job; + } + + const requestCursor = job.cursor; + let response: DispatchStatusResponse; + try { + response = await dispatchApi.status(job.jobId, requestCursor); + } catch (error) { + dispatchJobStore.getState().setTransportState( + job.jobId, + 'unreachable', + transportErrorMessage(error), + ); + throw error; + } + // A successful target status request is the only authoritative signal that + // clears a transient transport failure. It does not alter the durable job + // state beyond the snapshot applied below. + dispatchJobStore.getState().setTransportState(job.jobId, 'reachable'); + for (const event of response.events) { + const eventId = dispatchEventId(event); + if (dispatchJobStore.getState().hasAppliedEvent(job.jobId, eventId)) { + continue; + } + if (!applyEvent(context, event)) { + return; + } + // Persist each applied id immediately. If a later event in this response + // fails, the cursor stays put but already-applied chunks are not duplicated. + dispatchJobStore.getState().updateProgress(job.jobId, { + appliedEventIds: [eventId], + }); + } + + const terminalDrained = + isDispatchJobTerminal(response.state) && + requestCursor === response.cursor && + response.events.length === 0; + const sessionBeforeSnapshot = context.flowChatStore + .getState() + .sessions + .get(job.sessionId); + const dialogTurnsBeforeSnapshot = sessionBeforeSnapshot?.dialogTurns ?? []; + const lastTurnBeforeSnapshot = + dialogTurnsBeforeSnapshot[dialogTurnsBeforeSnapshot.length - 1]; + const terminalEventHandled = + !!lastTurnBeforeSnapshot && + ( + context.handledTerminalTurnEvents?.has( + `${job.sessionId}:${lastTurnBeforeSnapshot.id}`, + ) ?? false + ); + const needsTerminalFallback = terminalDrained && !terminalEventHandled; + const applied = context.flowChatStore.applyDispatchSnapshot(job.sessionId, { + jobId: job.jobId, + state: response.state, + cursor: response.cursor, + lastError: response.lastError, + expectedCursor: requestCursor, + cursorReset: response.cursorReset, + terminalDrained: needsTerminalFallback, + }); + if (!applied.applied) { + return; + } + dispatchJobStore.getState().updateProgress(job.jobId, { + cursor: response.cursor, + state: response.state, + lastError: response.lastError, + cursorReset: response.cursorReset, + terminalDrained, + }); + if (needsTerminalFallback) { + const effectiveSession = context.flowChatStore + .getState() + .sessions + .get(job.sessionId); + const effectiveState = effectiveSession?.config.dispatchJobState; + if (!effectiveState || !isDispatchJobTerminal(effectiveState)) { + return; + } + reconcileDispatchTerminalRuntime( + context, + job.sessionId, + effectiveState, + effectiveSession.config.dispatchLastError || + effectiveSession.error || + response.lastError, + ); + } +} + +export function installDispatchJobObserver(context: FlowChatContext): () => void { + let disposed = false; + let inFlight = false; + let queuedJobId: string | undefined; + let immediateTimer: ReturnType | null = null; + + async function run(requestedJobId?: string): Promise { + if (disposed || isPeerDeviceModeActive()) return; + if (inFlight) { + queuedJobId = requestedJobId; + return; + } + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + return; + } + + inFlight = true; + try { + const records = await dispatchApi.listJobs(); + dispatchJobStore.getState().mergeOutboundRecords( + records, + context.currentWorkspacePath || undefined, + ); + const jobs = Object.values(dispatchJobStore.getState().jobs) + .filter(job => !requestedJobId || job.jobId === requestedJobId); + for (const job of jobs) { + try { + await refreshJob(context, job.jobId); + } catch (error) { + log.warn('Dispatch job refresh failed', { jobId: job.jobId, error }); + } + } + } catch (error) { + const message = transportErrorMessage(error); + const jobs = Object.values(dispatchJobStore.getState().jobs) + .filter(job => ( + job.state !== 'submitting' && + (!requestedJobId || job.jobId === requestedJobId) && + !(isDispatchJobTerminal(job.state) && job.terminalDrained) + )); + for (const job of jobs) { + dispatchJobStore.getState().setTransportState( + job.jobId, + 'unreachable', + message, + ); + } + log.warn('Failed to reconcile outbound dispatch jobs', { error }); + } finally { + inFlight = false; + if (queuedJobId !== undefined && !disposed) { + const next = queuedJobId; + queuedJobId = undefined; + schedule(next); + } + } + } + + function schedule(jobId?: string): void { + if (disposed) return; + if (immediateTimer !== null) { + clearTimeout(immediateTimer); + } + immediateTimer = setTimeout(() => { + immediateTimer = null; + void run(jobId); + }, 0); + } + + installedRefreshRequester = schedule; + const interval = setInterval(() => { + void run(); + }, DISPATCH_JOB_POLL_INTERVAL_MS); + const handleVisibilityChanged = () => { + if (typeof document === 'undefined' || document.visibilityState === 'visible') { + schedule(); + } + }; + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibilityChanged); + } + schedule(); + + return () => { + disposed = true; + if (installedRefreshRequester === schedule) { + installedRefreshRequester = null; + } + if (immediateTimer !== null) { + clearTimeout(immediateTimer); + } + clearInterval(interval); + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', handleVisibilityChanged); + } + }; +} diff --git a/src/web-ui/src/features/dispatch/DispatchTargetPicker.scss b/src/web-ui/src/features/dispatch/DispatchTargetPicker.scss new file mode 100644 index 0000000000..c3ce306150 --- /dev/null +++ b/src/web-ui/src/features/dispatch/DispatchTargetPicker.scss @@ -0,0 +1,218 @@ +@use '../../component-library/styles/tokens' as *; + +.dispatch-target-picker { + display: inline-flex; + min-width: 0; + align-items: center; + + &__trigger { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; + max-width: 142px; + height: 18px; + padding: 0 5px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-accent-500) 28%, transparent); + border-radius: $size-radius-sm; + background: color-mix(in srgb, var(--color-accent-500) 7%, transparent); + color: var(--color-text-secondary); + font: inherit; + font-size: var(--flowchat-font-size-xxs); + line-height: 1; + cursor: pointer; + + &:hover:not(:disabled), + &[aria-expanded='true'] { + border-color: color-mix(in srgb, var(--color-accent-500) 52%, transparent); + background: color-mix(in srgb, var(--color-accent-500) 12%, transparent); + color: var(--color-text-primary); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: 1px; + } + + &:disabled { + cursor: default; + opacity: 0.8; + } + + > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + > svg { + flex: none; + } + } + + &__menu { + position: fixed; + z-index: 1200; + box-sizing: border-box; + display: flex; + width: min(300px, calc(100vw - 24px)); + max-height: min(410px, calc(100vh - 24px)); + flex-direction: column; + gap: 4px; + padding: 6px; + overflow: auto; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + background: var(--color-bg-elevated); + box-shadow: var(--shadow-lg); + color: var(--color-text-primary); + font-family: var(--font-family-sans); + user-select: none; + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; + padding: 2px 7px 5px; + color: var(--color-text-secondary); + font-size: var(--font-size-xxs); + font-weight: 600; + + small { + color: var(--color-text-muted); + font: inherit; + font-weight: 400; + } + } + + &__section { + display: flex; + flex-direction: column; + gap: 2px; + } + + &__section-title { + padding: 4px 8px 2px; + color: var(--color-text-muted); + font-size: var(--font-size-xxs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &__option { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 16px; + align-items: center; + gap: $size-gap-2; + width: 100%; + min-height: 46px; + padding: 7px 8px; + border: 0; + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-primary); + font: inherit; + text-align: left; + cursor: pointer; + + &:hover, + &:focus-visible, + &[aria-checked='true'] { + background: var(--element-bg-medium); + outline: none; + } + + &[aria-checked='true'] > svg:last-child { + color: var(--color-accent-500); + } + + > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + } + + strong, + small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { + font-size: var(--font-size-xs); + font-weight: 500; + } + + small { + color: var(--color-text-muted); + font-size: var(--font-size-xxs); + } + } + + &__status { + display: flex; + align-items: center; + gap: $size-gap-2; + padding: 9px 8px; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + } + + &__divider { + height: 1px; + margin: 3px 2px; + background: var(--border-subtle); + } + + &__footer-action { + display: flex; + align-items: center; + gap: $size-gap-2; + min-height: 32px; + padding: 6px 8px; + border: 0; + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-secondary); + font: inherit; + font-size: var(--font-size-xs); + cursor: pointer; + + &:hover, + &:focus-visible { + background: var(--element-bg-medium); + color: var(--color-text-primary); + outline: none; + } + } + + &__spin { + animation: dispatch-picker-spin 1s linear infinite; + } +} + +@keyframes dispatch-picker-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 560px) { + .dispatch-target-picker__trigger { + width: 18px; + padding: 0; + + > span, + > .dispatch-target-picker__chevron { + display: none; + } + } +} diff --git a/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx b/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx new file mode 100644 index 0000000000..5796de9ba9 --- /dev/null +++ b/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx @@ -0,0 +1,243 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Check, ChevronDown, Laptop, Loader2, Plus, Server } from 'lucide-react'; +import { Tooltip } from '@/component-library'; +import { SSHConnectionDialog } from '@/features/ssh-remote/SSHConnectionDialog'; +import { useI18n } from '@/infrastructure/i18n'; +import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; +import { DispatchInstallDialog } from './DispatchInstallDialog'; +import type { + DispatchSelection, + DispatchTarget, + DispatchTargetOption, +} from './types'; +import { useDispatchTargets } from './useDispatchTargets'; +import './DispatchTargetPicker.scss'; + +interface DispatchTargetPickerProps { + target: DispatchTarget; + locked: boolean; + disabled?: boolean; + onSelectLocal?: () => void; + onSelectSsh: (selection: DispatchSelection) => void; +} + +export const DispatchTargetPicker: React.FC = ({ + target, + locked, + disabled = false, + onSelectLocal, + onSelectSsh, +}) => { + const { t } = useI18n('flow-chat'); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const [open, setOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + const [configureTarget, setConfigureTarget] = useState(null); + const [sshDialogOpen, setSshDialogOpen] = useState(false); + const { targets, loading, error, refresh } = useDispatchTargets(open); + + const displayLabel = target.kind === 'local' + ? t('chatInput.dispatch.local') + : target.displayName; + const tooltip = locked + ? t('chatInput.dispatch.locked', { target: displayLabel }) + : t('chatInput.dispatch.current', { target: displayLabel }); + + const updatePosition = useCallback(() => { + const rect = triggerRef.current?.getBoundingClientRect(); + if (!rect) return; + const width = menuRef.current?.offsetWidth ?? 300; + const height = menuRef.current?.offsetHeight ?? 340; + setMenuPosition(computeFixedPopoverPosition(rect, width, height, 7, 8)); + }, []); + + useEffect(() => { + if (!open) return; + updatePosition(); + const frame = requestAnimationFrame(updatePosition); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [open, updatePosition]); + + useEffect(() => { + if (!open) return; + const handlePointerDown = (event: PointerEvent) => { + const node = event.target as Node; + if (!rootRef.current?.contains(node) && !menuRef.current?.contains(node)) { + setOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + const sshTargets = useMemo( + () => targets.filter( + (item): item is DispatchTargetOption & { kind: 'ssh'; connectionId: string } => + item.kind === 'ssh' && !!item.connectionId, + ), + [targets], + ); + + const menu = open ? createPortal( +
+
+ {t('chatInput.dispatch.menuLabel')} + {t('chatInput.dispatch.sessionScope')} +
+
+
+ {t('chatInput.dispatch.localSection')} +
+ +
+ +
+
+
+ {t('chatInput.dispatch.sshSection')} +
+ {loading ? ( +
+ + {t('chatInput.dispatch.loading')} +
+ ) : null} + {!loading && sshTargets.length === 0 ? ( +
+ {error || t('chatInput.dispatch.noSshTargets')} +
+ ) : null} + {sshTargets.map(option => { + const selected = target.kind === 'ssh' && target.connectionId === option.connectionId; + return ( + + ); + })} +
+ +
+ +
, + document.body, + ) : null; + + return ( + <> +
+ + + + {menu} +
+ + setConfigureTarget(null)} + onReady={selection => { + setConfigureTarget(null); + onSelectSsh(selection); + }} + /> + + { + setSshDialogOpen(false); + void refresh(); + }} + /> + + ); +}; diff --git a/src/web-ui/src/features/dispatch/README.md b/src/web-ui/src/features/dispatch/README.md new file mode 100644 index 0000000000..5639eae6f5 --- /dev/null +++ b/src/web-ui/src/features/dispatch/README.md @@ -0,0 +1,33 @@ +# Dispatch Web UI + +Phase one supports local execution and detached SSH dispatch only. + +## Invariants + +1. A dispatch target is selected while creating a session and is immutable after + the first turn. +2. `local` uses the existing session, worktree, persistence, and dialog-turn + paths unchanged. +3. A non-local session is an observer projection. The controller must not call + `create_session`, `bind_session_worktree`, `start_dialog_turn`, restore, or + local session persistence for it. +4. The target CLI owns the ordinary durable session and the append-only event + log. The controller owns only the outbound observer index and a UI cache. +5. Status cursors advance only after every returned event has been applied. + Agent envelope ids are deduplicated before replay. Terminal jobs keep + polling until an empty page confirms that the event log is fully drained. +6. SSH CLI installation is always a separate, explicit confirmation. The UI + displays the resolved version, URL, and SHA256 before starting it. +7. Phase one never lists or routes to account devices. Peer Device Mode keeps + every dispatch command on the controller because SSH credentials live there. +8. Unattended approval policy is explicit per job: `auto` or + `reject-and-report`. There is no implicit interactive mode. `auto` also + requires a one-shot, non-persisted confirmation immediately before submit. +9. MiniApp and quick-input hosts do not expose the dispatch picker. +10. Controller-side model settings never leak into an SSH dispatch. The submit + omits `model` unless preflight recorded an explicit target model choice. +11. Deleting or archiving a projection writes a local job tombstone so outbound + reconciliation cannot silently reopen it. +12. Phase one ignores `SubagentSessionLinked`. Child observer ownership is not + implemented, so creating an unmarked child projection would violate the + observer-only persistence and cancellation boundary. diff --git a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts new file mode 100644 index 0000000000..99bf4d81a5 --- /dev/null +++ b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts @@ -0,0 +1,69 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + BASE_DISPATCH_CAPABILITIES, + isDispatchWorkspaceReady, +} from './dispatchPreflight'; + +const OUTBOUND_DISPATCH_COMMANDS = [ + 'dispatch_list_targets', + 'dispatch_probe_target', + 'dispatch_install_cli_start', + 'dispatch_install_cli_poll', + 'dispatch_install_cli_cancel', + 'dispatch_submit', + 'dispatch_status', + 'dispatch_cancel', + 'dispatch_list_jobs', +] as const; + +function read(relativePath: string): string { + return readFileSync( + fileURLToPath(new URL(relativePath, import.meta.url)), + 'utf8', + ).replace(/\r\n/g, '\n'); +} + +describe('dispatch controller-only routing contract', () => { + const tables = [ + { + name: 'Web peer transport', + source: read('../../infrastructure/api/adapters/peer-device-adapter.ts'), + }, + { + name: 'Desktop peer host bridge', + source: read('../../../../../src/apps/desktop/src/api/peer_host_invoke.rs'), + }, + { + name: 'CLI peer host deny table', + source: read('../../../../../src/apps/cli/src/peer_host/deny.rs'), + }, + ]; + + for (const table of tables) { + it(`keeps every outbound command local in the ${table.name}`, () => { + for (const command of OUTBOUND_DISPATCH_COMMANDS) { + expect(table.source).toMatch(new RegExp(`['"]${command}['"]`)); + } + }); + } +}); + +describe('dispatch preflight contract', () => { + it('requires the workspace serialization capability enforced by submit', () => { + expect(BASE_DISPATCH_CAPABILITIES).toContain('workspace_serialization'); + }); + + it('invalidates workspace readiness when the input no longer matches the probe', () => { + const probe = { + path: '/repo', + exists: true, + isDirectory: true, + isGitRepository: true, + }; + + expect(isDispatchWorkspaceReady(' /repo ', probe)).toBe(true); + expect(isDispatchWorkspaceReady('/another-repo', probe)).toBe(false); + }); +}); diff --git a/src/web-ui/src/features/dispatch/dispatchApi.test.ts b/src/web-ui/src/features/dispatch/dispatchApi.test.ts new file mode 100644 index 0000000000..882e6c0675 --- /dev/null +++ b/src/web-ui/src/features/dispatch/dispatchApi.test.ts @@ -0,0 +1,26 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { dispatchApi } from './dispatchApi'; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), +})); + +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ + api: { + invoke: mocks.invoke, + }, +})); + +describe('dispatchApi', () => { + beforeEach(() => { + mocks.invoke.mockReset().mockResolvedValue([]); + }); + + it('wraps the target list command in the structured Tauri request contract', async () => { + await dispatchApi.listTargets(); + + expect(mocks.invoke).toHaveBeenCalledWith('dispatch_list_targets', { + request: {}, + }); + }); +}); diff --git a/src/web-ui/src/features/dispatch/dispatchApi.ts b/src/web-ui/src/features/dispatch/dispatchApi.ts new file mode 100644 index 0000000000..50ece1d405 --- /dev/null +++ b/src/web-ui/src/features/dispatch/dispatchApi.ts @@ -0,0 +1,89 @@ +import { api } from '@/infrastructure/api/service-api/ApiClient'; +import type { + DispatchApprovalPolicy, + DispatchCancelResponse, + DispatchCliRelease, + DispatchInstallPoll, + DispatchInstallStart, + DispatchJobListEntry, + DispatchSshProbe, + DispatchStatusResponse, + DispatchSubmitResponse, + DispatchTargetOption, + DispatchTargetRequest, + OutboundDispatchRecord, +} from './types'; + +export const dispatchApi = { + async listTargets(): Promise { + return api.invoke('dispatch_list_targets', { + request: {}, + }); + }, + + async probeTarget(target: DispatchTargetRequest): Promise { + return api.invoke('dispatch_probe_target', { + request: { target }, + }); + }, + + async installCliStart( + connectionId: string, + release: DispatchCliRelease, + ): Promise { + return api.invoke('dispatch_install_cli_start', { + request: { connectionId, release }, + }); + }, + + async installCliPoll(connectionId: string, cursor: number): Promise { + return api.invoke('dispatch_install_cli_poll', { + request: { connectionId, cursor }, + }); + }, + + async installCliCancel(connectionId: string): Promise { + return api.invoke('dispatch_install_cli_cancel', { + request: { connectionId }, + }); + }, + + async submit(request: { + target: DispatchTargetRequest; + jobId: string; + sessionId: string; + agentType: string; + prompt: string; + approvalPolicy: DispatchApprovalPolicy; + model?: string; + title?: string; + }): Promise { + return api.invoke('dispatch_submit', { + request, + }); + }, + + async status(jobId: string, cursor: number): Promise { + return api.invoke('dispatch_status', { + request: { jobId, cursor }, + }); + }, + + async cancel(jobId: string): Promise { + return api.invoke('dispatch_cancel', { + request: { jobId }, + }); + }, + + async listJobs(): Promise { + return api.invoke('dispatch_list_jobs', { + request: {}, + }); + }, + + async listTargetJobs(target: DispatchTargetRequest): Promise { + return api.invoke('dispatch_list_jobs', { + request: { target }, + }); + }, +}; diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts new file mode 100644 index 0000000000..e66cc0befa --- /dev/null +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment jsdom + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { dispatchJobStore } from './dispatchJobStore'; + +function registerJob(state: 'running' | 'succeeded' = 'running'): void { + dispatchJobStore.getState().registerJob({ + jobId: 'job-1', + sessionId: 'session-1', + targetRequest: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/repo', + }, + target: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/repo', + displayName: 'build-host', + }, + title: 'Dispatch test', + agentType: 'agentic', + approvalPolicy: 'reject-and-report', + cursor: 10, + state, + terminalDrained: state === 'succeeded', + appliedEventIds: [], + createdAt: 1, + updatedAt: 1, + }); +} + +describe('dispatchJobStore', () => { + beforeEach(() => { + dispatchJobStore.getState().clear(); + }); + + it('keeps cursors monotonic and clears terminal-drained state on progress', () => { + registerJob(); + dispatchJobStore.getState().updateProgress('job-1', { + cursor: 20, + terminalDrained: true, + }); + dispatchJobStore.getState().updateProgress('job-1', { + cursor: 12, + }); + + expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ + cursor: 20, + state: 'running', + terminalDrained: true, + }); + + dispatchJobStore.getState().updateProgress('job-1', { + cursor: 21, + }); + expect(dispatchJobStore.getState().jobs['job-1'].terminalDrained).toBe(false); + }); + + it('never regresses a terminal state from a stale status or outbound record', () => { + registerJob('succeeded'); + dispatchJobStore.getState().updateProgress('job-1', { + state: 'running', + cursor: 11, + }); + dispatchJobStore.getState().mergeOutboundRecords([{ + jobId: 'job-1', + sessionId: 'session-1', + target: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/repo', + displayName: 'build-host', + }, + workspacePath: '/repo', + promptPreview: 'Dispatch test', + lastCursor: 9, + lastState: 'queued', + createdAt: '2026-07-28T00:00:00Z', + updatedAt: '2026-07-28T00:00:01Z', + }]); + + expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ + state: 'succeeded', + cursor: 11, + }); + }); + + it('persists a dismissal tombstone so reconciliation cannot reopen the projection', () => { + registerJob(); + dispatchJobStore.getState().dismissJob('job-1'); + dispatchJobStore.getState().mergeOutboundRecords([{ + jobId: 'job-1', + sessionId: 'session-1', + target: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/repo', + displayName: 'build-host', + }, + workspacePath: '/repo', + promptPreview: 'Dispatch test', + lastCursor: 10, + lastState: 'running', + createdAt: '2026-07-28T00:00:00Z', + updatedAt: '2026-07-28T00:00:01Z', + }]); + + expect(dispatchJobStore.getState().jobs['job-1']).toBeUndefined(); + expect(dispatchJobStore.getState().dismissedJobIds).toContain('job-1'); + }); + + it('keeps transport reachability transient and separate from authoritative job state', () => { + registerJob(); + dispatchJobStore.getState().setTransportState( + 'job-1', + 'unreachable', + 'SSH target is offline', + ); + + expect(dispatchJobStore.getState().jobs['job-1'].state).toBe('running'); + expect(dispatchJobStore.getState().transportByJobId['job-1']).toEqual({ + reachability: 'unreachable', + lastTransportError: 'SSH target is offline', + }); + + const partialize = dispatchJobStore.persist.getOptions().partialize; + const persistedState = partialize?.( + dispatchJobStore.getState(), + ) as Record | undefined; + expect(persistedState?.transportByJobId).toBeUndefined(); + }); +}); diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.ts new file mode 100644 index 0000000000..e9c08ce2d4 --- /dev/null +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.ts @@ -0,0 +1,340 @@ +import { create } from 'zustand'; +import { + createJSONStorage, + persist, + type StateStorage, +} from 'zustand/middleware'; +import type { + DispatchApprovalPolicy, + DispatchJobState, + DispatchReachability, + DispatchTarget, + DispatchTargetRequest, + OutboundDispatchRecord, +} from './types'; +import { isDispatchJobTerminal } from './types'; + +const MAX_APPLIED_EVENT_IDS = 2048; +const MAX_DISMISSED_JOB_IDS = 2048; +const fallbackStorageValues = new Map(); +const fallbackStorage: StateStorage = { + getItem: (name) => fallbackStorageValues.get(name) ?? null, + setItem: (name, value) => { + fallbackStorageValues.set(name, value); + }, + removeItem: (name) => { + fallbackStorageValues.delete(name); + }, +}; + +export interface DispatchObserverJob { + jobId: string; + sessionId: string; + targetRequest: DispatchTargetRequest; + target: DispatchTarget; + sourceWorkspacePath?: string; + sourceWorkspaceId?: string; + title: string; + agentType: string; + approvalPolicy: DispatchApprovalPolicy; + model?: string; + cursor: number; + state: DispatchJobState; + terminalDrained?: boolean; + lastError?: string; + appliedEventIds: string[]; + createdAt: number; + updatedAt: number; +} + +export interface DispatchTransportState { + reachability: DispatchReachability; + lastTransportError?: string; +} + +interface DispatchJobStoreState { + jobs: Record; + /** + * Live controller-to-target transport health. This is deliberately excluded + * from persistence because only a current poll can establish reachability. + */ + transportByJobId: Record; + /** Local projection tombstones. The target job remains durable, but must not reopen in navigation. */ + dismissedJobIds: string[]; + registerJob: (job: DispatchObserverJob) => void; + mergeOutboundRecords: ( + records: OutboundDispatchRecord[], + fallbackSourceWorkspacePath?: string, + ) => void; + updateProgress: ( + jobId: string, + update: { + cursor?: number; + state?: DispatchJobState; + lastError?: string; + appliedEventIds?: string[]; + terminalDrained?: boolean; + cursorReset?: boolean; + }, + ) => void; + hasAppliedEvent: (jobId: string, eventId: string) => boolean; + setTransportState: ( + jobId: string, + reachability: DispatchReachability, + lastTransportError?: string, + ) => void; + resetReplay: (jobId: string) => void; + updateTitle: (jobId: string, title: string) => void; + dismissJob: (jobId: string) => void; + removeJob: (jobId: string) => void; + clear: () => void; +} + +function nextJobState( + current: DispatchJobState, + requested: DispatchJobState | undefined, +): DispatchJobState { + if (!requested || isDispatchJobTerminal(current)) { + return current; + } + return requested; +} + +function requestFromTarget(target: DispatchTarget): DispatchTargetRequest { + switch (target.kind) { + case 'ssh': + return { + kind: 'ssh', + connectionId: target.connectionId, + workspacePath: target.workspacePath, + }; + case 'device': + return { + kind: 'device', + deviceId: target.deviceId, + workspacePath: target.workspacePath, + }; + default: + return { kind: 'local' }; + } +} + +export const useDispatchJobStore = create()( + persist( + (set, get) => ({ + jobs: {}, + transportByJobId: {}, + dismissedJobIds: [], + + registerJob: (job) => { + set(state => { + const transportByJobId = { + ...state.transportByJobId, + [job.jobId]: state.transportByJobId[job.jobId] ?? { + reachability: 'unknown' as const, + }, + }; + return { + jobs: { + ...state.jobs, + [job.jobId]: { + ...job, + cursor: Math.max(0, job.cursor), + appliedEventIds: job.appliedEventIds.slice(-MAX_APPLIED_EVENT_IDS), + }, + }, + transportByJobId, + dismissedJobIds: state.dismissedJobIds.filter(id => id !== job.jobId), + }; + }); + }, + + mergeOutboundRecords: (records, fallbackSourceWorkspacePath) => { + set(state => { + const jobs = { ...state.jobs }; + for (const record of records) { + if (state.dismissedJobIds.includes(record.jobId)) { + continue; + } + const existing = jobs[record.jobId]; + if (existing) { + const nextState = nextJobState(existing.state, record.lastState); + const progressed = + record.lastCursor > existing.cursor || + nextState !== existing.state; + jobs[record.jobId] = { + ...existing, + target: record.target, + targetRequest: requestFromTarget(record.target), + cursor: Math.max(existing.cursor, record.lastCursor), + state: nextState, + terminalDrained: progressed ? false : existing.terminalDrained, + updatedAt: Math.max(existing.updatedAt, Date.parse(record.updatedAt) || 0), + }; + continue; + } + jobs[record.jobId] = { + jobId: record.jobId, + sessionId: record.sessionId, + targetRequest: requestFromTarget(record.target), + target: record.target, + sourceWorkspacePath: fallbackSourceWorkspacePath, + title: record.promptPreview || record.sessionId.slice(0, 8), + agentType: 'agentic', + approvalPolicy: 'reject-and-report', + cursor: record.lastCursor, + state: record.lastState, + terminalDrained: false, + appliedEventIds: [], + createdAt: Date.parse(record.createdAt) || Date.now(), + updatedAt: Date.parse(record.updatedAt) || Date.now(), + }; + } + const transportByJobId = { ...state.transportByJobId }; + for (const jobId of Object.keys(jobs)) { + transportByJobId[jobId] ??= { reachability: 'unknown' }; + } + return { jobs, transportByJobId }; + }); + }, + + updateProgress: (jobId, update) => { + set(state => { + const current = state.jobs[jobId]; + if (!current) return state; + const eventIds = update.appliedEventIds + ? Array.from(new Set([...current.appliedEventIds, ...update.appliedEventIds])) + .slice(-MAX_APPLIED_EVENT_IDS) + : current.appliedEventIds; + const nextCursor = update.cursorReset + ? Math.max(0, update.cursor ?? 0) + : Math.max(current.cursor, update.cursor ?? current.cursor); + const nextState = nextJobState(current.state, update.state); + const progressed = nextCursor > current.cursor || nextState !== current.state; + return { + jobs: { + ...state.jobs, + [jobId]: { + ...current, + cursor: nextCursor, + state: nextState, + terminalDrained: update.terminalDrained ?? ( + progressed ? false : current.terminalDrained + ), + lastError: update.lastError, + appliedEventIds: eventIds, + updatedAt: Date.now(), + }, + }, + }; + }); + }, + + hasAppliedEvent: (jobId, eventId) => + get().jobs[jobId]?.appliedEventIds.includes(eventId) ?? false, + + setTransportState: (jobId, reachability, lastTransportError) => { + set(state => { + if (!state.jobs[jobId]) return state; + const current = state.transportByJobId[jobId]; + const normalizedError = lastTransportError?.trim() || undefined; + if ( + current?.reachability === reachability && + current.lastTransportError === normalizedError + ) { + return state; + } + return { + transportByJobId: { + ...state.transportByJobId, + [jobId]: { + reachability, + lastTransportError: normalizedError, + }, + }, + }; + }); + }, + + resetReplay: (jobId) => { + set(state => { + const current = state.jobs[jobId]; + if (!current) return state; + return { + jobs: { + ...state.jobs, + [jobId]: { + ...current, + cursor: 0, + terminalDrained: false, + appliedEventIds: [], + updatedAt: Date.now(), + }, + }, + }; + }); + }, + + updateTitle: (jobId, title) => { + set(state => { + const current = state.jobs[jobId]; + const normalizedTitle = title.trim(); + if (!current || !normalizedTitle || current.title === normalizedTitle) { + return state; + } + return { + jobs: { + ...state.jobs, + [jobId]: { + ...current, + title: normalizedTitle, + updatedAt: Date.now(), + }, + }, + }; + }); + }, + + dismissJob: (jobId) => { + set(state => { + const jobs = { ...state.jobs }; + const transportByJobId = { ...state.transportByJobId }; + delete jobs[jobId]; + delete transportByJobId[jobId]; + return { + jobs, + transportByJobId, + dismissedJobIds: Array.from(new Set([...state.dismissedJobIds, jobId])) + .slice(-MAX_DISMISSED_JOB_IDS), + }; + }); + }, + + removeJob: (jobId) => { + set(state => { + if (!(jobId in state.jobs)) return state; + const jobs = { ...state.jobs }; + const transportByJobId = { ...state.transportByJobId }; + delete jobs[jobId]; + delete transportByJobId[jobId]; + return { jobs, transportByJobId }; + }); + }, + + clear: () => set({ jobs: {}, transportByJobId: {}, dismissedJobIds: [] }), + }), + { + name: 'bitfun-dispatch-jobs-v1', + version: 1, + storage: createJSONStorage(() => ( + typeof localStorage === 'undefined' ? fallbackStorage : localStorage + )), + partialize: state => ({ + jobs: state.jobs, + dismissedJobIds: state.dismissedJobIds, + }), + }, + ), +); + +export const dispatchJobStore = useDispatchJobStore; diff --git a/src/web-ui/src/features/dispatch/dispatchNavPresentation.test.ts b/src/web-ui/src/features/dispatch/dispatchNavPresentation.test.ts new file mode 100644 index 0000000000..a745ef2ce1 --- /dev/null +++ b/src/web-ui/src/features/dispatch/dispatchNavPresentation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { resolveDispatchNavPresentation } from './dispatchNavPresentation'; + +describe('resolveDispatchNavPresentation', () => { + it('shows target unreachability in both the navigation badge and tooltip summary', () => { + const presentation = resolveDispatchNavPresentation({ + targetLabel: 'build-host', + state: 'running', + reachability: 'unreachable', + runningSummary: 'Runs on build-host · running', + unreachableLabel: 'Target unreachable', + unreachableSummary: 'Target unreachable: build-host · SSH target is offline', + }); + + expect(presentation).toEqual({ + badgeLabel: 'Target unreachable', + summary: 'Target unreachable: build-host · SSH target is offline', + visualState: 'unreachable', + }); + }); + + it('keeps the authoritative job state presentation after transport recovery', () => { + const presentation = resolveDispatchNavPresentation({ + targetLabel: 'build-host', + state: 'running', + reachability: 'reachable', + runningSummary: 'Runs on build-host · running', + unreachableLabel: 'Target unreachable', + unreachableSummary: 'Target unreachable: build-host · stale error', + }); + + expect(presentation).toEqual({ + badgeLabel: 'build-host', + summary: 'Runs on build-host · running', + visualState: 'running', + }); + }); +}); diff --git a/src/web-ui/src/features/dispatch/dispatchNavPresentation.ts b/src/web-ui/src/features/dispatch/dispatchNavPresentation.ts new file mode 100644 index 0000000000..aa3220deab --- /dev/null +++ b/src/web-ui/src/features/dispatch/dispatchNavPresentation.ts @@ -0,0 +1,37 @@ +import type { DispatchJobState, DispatchReachability } from './types'; + +interface DispatchNavPresentationInput { + targetLabel: string; + state: DispatchJobState; + reachability?: DispatchReachability; + runningSummary: string; + unreachableLabel: string; + unreachableSummary: string; +} + +export interface DispatchNavPresentation { + badgeLabel: string; + summary: string; + visualState: DispatchJobState | 'unreachable'; +} + +/** + * Transport reachability is presentation-only. It can override the badge + * treatment while leaving the target's authoritative job state untouched. + */ +export function resolveDispatchNavPresentation( + input: DispatchNavPresentationInput, +): DispatchNavPresentation { + if (input.reachability === 'unreachable') { + return { + badgeLabel: input.unreachableLabel, + summary: input.unreachableSummary, + visualState: 'unreachable', + }; + } + return { + badgeLabel: input.targetLabel, + summary: input.runningSummary, + visualState: input.state, + }; +} diff --git a/src/web-ui/src/features/dispatch/dispatchPreflight.ts b/src/web-ui/src/features/dispatch/dispatchPreflight.ts new file mode 100644 index 0000000000..45eaeadd79 --- /dev/null +++ b/src/web-ui/src/features/dispatch/dispatchPreflight.ts @@ -0,0 +1,24 @@ +import type { DispatchWorkspaceProbe } from './types'; + +export const DISPATCH_PROTOCOL_VERSION = 1; + +export const BASE_DISPATCH_CAPABILITIES = [ + 'persistent_jobs', + 'cursor_events', + 'detached_worker', + 'frontend_event_projection', + 'workspace_serialization', +] as const; + +export function isDispatchWorkspaceReady( + workspacePath: string, + workspace: DispatchWorkspaceProbe | undefined, +): boolean { + const normalizedPath = workspacePath.trim(); + return ( + normalizedPath.length > 0 && + workspace?.path === normalizedPath && + workspace.exists === true && + workspace.isDirectory === true + ); +} diff --git a/src/web-ui/src/features/dispatch/types.ts b/src/web-ui/src/features/dispatch/types.ts new file mode 100644 index 0000000000..c5fe93ac5d --- /dev/null +++ b/src/web-ui/src/features/dispatch/types.ts @@ -0,0 +1,189 @@ +export type DispatchTargetRequest = + | { kind: 'local' } + | { kind: 'ssh'; connectionId: string; workspacePath: string } + | { kind: 'device'; deviceId: string; workspacePath: string }; + +export type DispatchTarget = + | { kind: 'local' } + | { + kind: 'ssh'; + connectionId: string; + workspacePath: string; + displayName: string; + } + | { + kind: 'device'; + deviceId: string; + workspacePath: string; + displayName: string; + }; + +export type DispatchApprovalPolicy = 'auto' | 'reject-and-report'; +export type DispatchReachability = 'unknown' | 'reachable' | 'unreachable'; +export type DispatchJobState = + | 'submitting' + | 'submission_unknown' + | 'queued' + | 'running' + | 'succeeded' + | 'failed' + | 'cancelled'; + +export interface DispatchTargetOption { + kind: 'local' | 'ssh'; + connectionId?: string; + displayName: string; + description?: string; + defaultWorkspace?: string; +} + +export interface DispatchCliRelease { + version: string; + target: string; + url: string; + sha256: string; +} + +export interface DispatchWorkspaceProbe { + path: string; + exists: boolean; + isDirectory: boolean; + isGitRepository: boolean; + branch?: string; + dirty?: boolean; + ahead?: number; + behind?: number; +} + +export interface DispatchProtocolProbe { + protocolVersion: number; + cliVersion: string; + os: string; + arch: string; + capabilities: string[]; + modelConfigured: boolean; + availableModels: string[]; + defaultModel?: string; + modelDiagnostic?: string; + workspace?: DispatchWorkspaceProbe; +} + +export interface DispatchSshProbe { + cliInstalled: boolean; + cliPath?: string; + os: string; + arch: string; + installSupported: boolean; + installError?: string; + protocolError?: string; + release?: DispatchCliRelease; + protocol?: DispatchProtocolProbe; +} + +export interface DispatchInstallStart { + scriptPath: string; + version: string; + target: string; + url: string; + sha256: string; +} + +export interface DispatchInstallPoll { + cursor: number; + output: string; + status: 'running' | 'succeeded' | 'failed'; +} + +export type DispatchEvent = + | { + type: 'audit'; + timestamp: string; + action: string; + details: Record; + } + | { + type: 'jobState'; + timestamp: string; + state: Exclude; + message?: string; + } + | { + type: 'agentEvent'; + timestamp: string; + event: DispatchAgentEventEnvelope | Record; + frontendEventName?: string; + frontendPayload?: Record; + } + | { + type: 'permissionRejected'; + timestamp: string; + request: Record; + reason: string; + }; + +export interface DispatchAgentEventEnvelope { + id?: string; + event?: Record; + priority?: string | number; + timestamp?: unknown; + frontendEventName?: string; + frontendPayload?: Record; +} + +export interface DispatchSubmitResponse { + accepted: boolean; + jobId: string; + sessionId: string; + state: Exclude; +} + +export interface DispatchStatusResponse { + state: Exclude; + cursor: number; + events: DispatchEvent[]; + pendingPermissions: Array>; + cursorReset: boolean; + lastError?: string; +} + +export interface DispatchCancelResponse { + cancelled: boolean; +} + +export interface DispatchJobListEntry { + jobId: string; + sessionId: string; + state: Exclude; + startedAt?: string; + workspacePath: string; + title: string; +} + +export interface OutboundDispatchRecord { + jobId: string; + target: DispatchTarget; + sessionId: string; + workspacePath: string; + promptPreview: string; + lastCursor: number; + lastState: DispatchJobState; + createdAt: string; + updatedAt: string; +} + +export interface DispatchSelection { + request: Extract; + target: Extract; + approvalPolicy: DispatchApprovalPolicy; + model?: string; +} + +export function isNonLocalDispatchTarget( + target: DispatchTargetRequest | DispatchTarget | undefined, +): target is Exclude { + return !!target && target.kind !== 'local'; +} + +export function isDispatchJobTerminal(state: DispatchJobState | undefined): boolean { + return state === 'succeeded' || state === 'failed' || state === 'cancelled'; +} diff --git a/src/web-ui/src/features/dispatch/useDispatchTargets.ts b/src/web-ui/src/features/dispatch/useDispatchTargets.ts new file mode 100644 index 0000000000..27ad0354dd --- /dev/null +++ b/src/web-ui/src/features/dispatch/useDispatchTargets.ts @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useState } from 'react'; +import { createLogger } from '@/shared/utils/logger'; +import { dispatchApi } from './dispatchApi'; +import type { DispatchTargetOption } from './types'; + +const log = createLogger('DispatchTargets'); + +export function useDispatchTargets(enabled = true): { + targets: DispatchTargetOption[]; + loading: boolean; + error: string | null; + refresh: () => Promise; +} { + const [targets, setTargets] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + if (!enabled) return; + setLoading(true); + setError(null); + try { + const nextTargets = await dispatchApi.listTargets(); + setTargets(nextTargets.filter(target => target.kind === 'local' || target.kind === 'ssh')); + } catch (nextError) { + const message = nextError instanceof Error ? nextError.message : String(nextError); + log.warn('Failed to list dispatch targets', { error: nextError }); + setError(message); + setTargets([{ kind: 'local', displayName: 'Local' }]); + } finally { + setLoading(false); + } + }, [enabled]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { targets, loading, error, refresh }; +} diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 7a2b602d04..4a54f015e8 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -123,6 +123,8 @@ import { ChatInputWorkspaceStrip, type ChatInputPermissionMode, } from './ChatInputWorkspaceStrip'; +import type { DispatchSelection, DispatchTarget } from '@/features/dispatch/types'; +import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; import { ComposerVoiceInputButton } from './voice/ComposerVoiceInputButton'; import { useComposerVoiceInput } from './voice/useComposerVoiceInput'; import { expandWidgetPromptReferenceTokens } from '@/tools/generative-widget/widgetPromptReference'; @@ -477,6 +479,10 @@ export const ChatInput: React.FC = ({ const effectiveTargetSession = effectiveTargetSessionId ? flowChatState.sessions.get(effectiveTargetSessionId) : undefined; + const isDispatchInputSession = isNonLocalDispatchTarget( + effectiveTargetSession?.config.dispatchTarget, + ); + const usesDispatchTransport = !registration && isDispatchInputSession; const historySessionOpenTransition = useSyncExternalStore( subscribeHistorySessionOpenTransition, getHistorySessionOpenTransitionSnapshot, @@ -1035,7 +1041,10 @@ export const ChatInput: React.FC = ({ `${s.remoteConnectionId ?? ''}|${s.remoteSshHost ?? ''}|${s.lastSubmittedMode ?? ''}|` + `${s.currentAcpContextUsage?.used ?? ''}|${s.currentAcpContextUsage?.size ?? ''}|` + `${s.currentTokenUsage?.inputTokens ?? ''}|${s.maxContextTokens ?? ''}|` + - `${s.needsUserAttention ? '1':'0'}|${sessionWorktreeBindingSubscriptionKey(s)}` + `${s.needsUserAttention ? '1':'0'}|${s.dialogTurns.length}|` + + `${JSON.stringify(s.config.dispatchTarget ?? null)}|` + + `${s.config.dispatchApprovalPolicy ?? ''}|${s.config.dispatchJobState ?? ''}|` + + `${sessionWorktreeBindingSubscriptionKey(s)}` ); } } @@ -1902,6 +1911,7 @@ export const ChatInput: React.FC = ({ const worktreeControl = useMemo(() => { if (!effectiveTargetSessionId || !effectiveTargetSession) return undefined; if (effectiveTargetSession.remoteConnectionId) return undefined; + if (usesDispatchTransport) return undefined; if (isSubagentInputTarget || isAcpTargetSession) return undefined; const locked = isSessionWorktreeBindingLocked( @@ -1937,6 +1947,57 @@ export const ChatInput: React.FC = ({ isAcpTargetSession, isSubagentInputTarget, tWorktrees, + usesDispatchTransport, + ]); + + const handleSelectDispatchSsh = useCallback(async (selection: DispatchSelection) => { + try { + await FlowChatManager.getInstance().createChatSession( + { + ...flowChatSessionConfigForCurrentWorkspace(workspace), + dispatchTargetRequest: selection.request, + dispatchTarget: selection.target, + dispatchApprovalPolicy: selection.approvalPolicy, + // Undefined is intentional: the target's probed default model wins + // unless a future preflight selector records an explicit choice. + dispatchModel: selection.model, + }, + effectiveSendAgentType, + ); + } catch (error) { + log.error('Failed to create dispatched session projection', { error }); + notificationService.error(t('chatInput.dispatch.createFailed')); + } + }, [effectiveSendAgentType, t, workspace]); + + const dispatchControl = useMemo(() => { + if ( + registration || + isBtwSession || + isSubagentInputTarget || + isAcpInputSession + ) { + return undefined; + } + const target: DispatchTarget = + effectiveTargetSession?.config.dispatchTarget ?? { kind: 'local' }; + return { + target, + locked: + isNonLocalDispatchTarget(target) || + (effectiveTargetSession?.dialogTurns.length ?? 0) > 0 || + !!derivedState?.isProcessing, + onSelectSsh: handleSelectDispatchSsh, + }; + }, [ + derivedState?.isProcessing, + effectiveTargetSession?.config.dispatchTarget, + effectiveTargetSession?.dialogTurns.length, + handleSelectDispatchSsh, + isAcpInputSession, + isBtwSession, + isSubagentInputTarget, + registration, ]); const handleHidePermissionModeControl = useCallback(async () => { @@ -2644,7 +2705,7 @@ export const ChatInput: React.FC = ({ setSelectedNonExternalSlashCandidateId(undefined); } - const localSlashCommandsEnabled = !isAcpInputSession; + const localSlashCommandsEnabled = !isAcpInputSession && !usesDispatchTransport; const trimmed = text.trim(); const isBtwCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/btw'); const isCompactCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/compact'); @@ -2722,7 +2783,7 @@ export const ChatInput: React.FC = ({ selectedIndex: 0, }); } - }, [contexts, derivedState, dispatchInput, externalPromptCommands, inputState.isActive, isAcpInputSession, prunePendingLargePastes, removeContext, resolveTypedMcpPromptCommand, selectedExternalPromptCandidateId, selectedNonExternalSlashCommand, setQueuedInput, slashCommandState.isActive, slashCommandState.kind]); + }, [contexts, derivedState, dispatchInput, externalPromptCommands, inputState.isActive, isAcpInputSession, prunePendingLargePastes, removeContext, resolveTypedMcpPromptCommand, selectedExternalPromptCandidateId, selectedNonExternalSlashCommand, setQueuedInput, slashCommandState.isActive, slashCommandState.kind, usesDispatchTransport]); const submitBtwFromInput = useCallback(async () => { if (!derivedState) return; @@ -3645,7 +3706,10 @@ export const ChatInput: React.FC = ({ : expandedMessage); const messageCharCount = getCharacterCount(message); // Voice transcripts are always message content; they must not accidentally execute local commands. - const localSlashCommandsEnabled = !isAcpInputSession && messageOverride === undefined; + const localSlashCommandsEnabled = + !isAcpInputSession && + !usesDispatchTransport && + messageOverride === undefined; if (localSlashCommandsEnabled && await submitExternalPromptCommandFromInput( message, @@ -3741,6 +3805,27 @@ export const ChatInput: React.FC = ({ return; } + let dispatchAutoConfirmed = false; + if ( + usesDispatchTransport && + effectiveTargetSession?.config.dispatchApprovalPolicy === 'auto' + ) { + const targetLabel = effectiveTargetSession.config.dispatchTarget?.kind === 'ssh' + ? effectiveTargetSession.config.dispatchTarget.displayName + : t('chatInput.dispatch.remoteTarget'); + dispatchAutoConfirmed = await confirmWarning( + t('chatInput.dispatch.autoConfirmTitle'), + t('chatInput.dispatch.autoConfirmMessage', { target: targetLabel }), + { + confirmText: t('chatInput.dispatch.autoConfirmAction'), + cancelText: t('chatInput.dispatch.autoConfirmCancel'), + }, + ); + if (!dispatchAutoConfirmed) { + return; + } + } + // Add to history before clearing (session-scoped) if (effectiveTargetSessionId) { addToHistory(effectiveTargetSessionId, message); @@ -3767,6 +3852,7 @@ export const ChatInput: React.FC = ({ () => sendMessage(message, { displayMessage: originalMessage, composerPresentation: persistedComposerPresentation, + dispatchAutoConfirmed, }), ); if (transport === 'registered') { @@ -3800,6 +3886,8 @@ export const ChatInput: React.FC = ({ onSendMessage, addToHistory, effectiveTargetSessionId, + effectiveTargetSession?.config.dispatchApprovalPolicy, + effectiveTargetSession?.config.dispatchTarget, clearPendingLargePastes, expandComposerSpecialTokens, isAcpInputSession, @@ -3818,6 +3906,7 @@ export const ChatInput: React.FC = ({ t, resolveTypedMcpPromptCommand, submitExternalPromptCommandFromInput, + usesDispatchTransport, ]); const getFilteredIncrementalModes = useCallback(() => { @@ -5352,7 +5441,7 @@ export const ChatInput: React.FC = ({
- {voiceInput.phase === 'idle' ? ( + {voiceInput.phase === 'idle' && !usesDispatchTransport ? (
= ({ repositoryPath={chatStripRepositoryPath} workspaceLabel={chatStripWorkspaceLabel} executionTarget={effectiveTargetSession?.config.executionTarget} + dispatchControl={dispatchControl} worktreeControl={worktreeControl} deferPassiveGitRefresh={deferChatStripPassiveGitRefresh} - permissionControl={showPermissionModeControl ? { + permissionControl={showPermissionModeControl && !usesDispatchTransport ? { mode: permissionMode, saving: permissionModeSaving, onChange: isAcpTargetSession ? undefined : handlePermissionModeChange, onHide: isAcpTargetSession ? undefined : handleHidePermissionModeControl, } : undefined} usageReport={ - effectiveTargetSessionId && effectiveTargetSession + effectiveTargetSessionId && effectiveTargetSession && !usesDispatchTransport ? { visible: true, onOpen: handleToolbarUsageReport } : undefined } threadGoal={ - effectiveTargetSessionId && effectiveTargetSession && !isBtwSession + effectiveTargetSessionId && + effectiveTargetSession && + !isBtwSession && + !usesDispatchTransport ? { visible: true, goal: threadGoalController.goal, @@ -5402,7 +5495,7 @@ export const ChatInput: React.FC = ({ : undefined } /> - {effectiveTargetSession && !isBtwSession ? ( + {effectiveTargetSession && !isBtwSession && !usesDispatchTransport ? ( void; }; + /** Immutable per-session dispatch destination. Hidden on embedded/mini composers. */ + dispatchControl?: { + target: DispatchTarget; + locked: boolean; + onSelectLocal?: () => void; + onSelectSsh: (selection: DispatchSelection) => void; + }; } export type ChatInputPermissionMode = 'ask' | 'auto' | 'full_access' | 'acp'; @@ -80,6 +89,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( deferPassiveGitRefresh = false, executionTarget, worktreeControl, + dispatchControl, }) => { const { t } = useTranslation('flow-chat'); const { t: tWorktrees } = useI18n('worktrees'); @@ -112,7 +122,8 @@ export const ChatInputWorkspaceStrip: React.FC = ( const showUsage = usageReport?.visible && !!usageReport.onOpen; const showGoal = threadGoal?.visible && !!threadGoal.onOpen; const showPermission = !!permissionControl; - const showRightActions = showPermission || showUsage || showGoal; + const showDispatch = !!dispatchControl; + const showRightActions = showDispatch || showPermission || showUsage || showGoal; const isWorktree = !!executionTarget?.worktreeId; const worktreeEnabled = worktreeControl?.enabled ?? isWorktree; const worktreeEnabledRef = useRef(worktreeEnabled); @@ -292,6 +303,14 @@ export const ChatInputWorkspaceStrip: React.FC = ( {showRightActions ? (
+ {dispatchControl ? ( + + ) : null} {showPermission ? (
{ it('keeps the session usage action visible without overpowering the strip', () => { const stylesheet = readWorkspaceStripStylesheet(); @@ -37,4 +51,22 @@ describe('ChatInputWorkspaceStrip layout styles', () => { expect(stylesheet).toContain('&__permission-label'); expect(stylesheet).toContain('display: none;'); }); + + it('places dispatch first in right actions and protects the narrow layout', () => { + const component = readWorkspaceStripComponent(); + const pickerStylesheet = readDispatchPickerStylesheet(); + const actionsStart = component.indexOf( + '
', + ); + const dispatchIndex = component.indexOf(' .dispatch-target-picker__chevron'); + }); }); diff --git a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts index 2f2545dba4..c8312170d5 100644 --- a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts +++ b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts @@ -49,6 +49,8 @@ interface UseMessageSenderReturn { options?: { displayMessage?: string; composerPresentation?: ComposerPresentation | null; + /** One-shot UI confirmation for unattended auto approval. */ + dispatchAutoConfirmed?: boolean; } ) => Promise; /** Whether a send is in progress */ @@ -70,6 +72,8 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender options?: { displayMessage?: string; composerPresentation?: ComposerPresentation | null; + /** One-shot UI confirmation for unattended auto approval. */ + dispatchAutoConfirmed?: boolean; } ) => { if (!message.trim()) { @@ -183,6 +187,7 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender { ...(imagePayload ?? {}), ...(userMessageMetadata ? { userMessageMetadata } : {}), + ...(options?.dispatchAutoConfirmed ? { dispatchAutoConfirmed: true } : {}), } ); diff --git a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts index 84f7f149ce..3753a4dfd0 100644 --- a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts @@ -62,6 +62,7 @@ export interface AgenticEventCallbacks { export class AgenticEventListener { private unlistenFunctions: UnlistenFn[] = []; private isListening = false; + private callbacks: AgenticEventCallbacks | null = null; async startListening(callbacks: AgenticEventCallbacks): Promise { if (this.isListening) { @@ -72,6 +73,7 @@ export class AgenticEventListener { logger.info('Starting Agentic event listener'); try { + this.callbacks = callbacks; if (callbacks.onSessionCreated) { const unlisten = agentAPI.onSessionCreated((event) => { logger.debug('Session created:', event); @@ -287,6 +289,114 @@ export class AgenticEventListener { } } + /** + * Feed a durable event obtained through another transport into the same + * handlers as live `agentic://*` events. Dispatch observers use this instead + * of creating a second transcript reducer. + * + * `false` means the normal listener is not ready; callers must retain their + * cursor and retry rather than dropping the event. + */ + dispatchExternal(eventName: string, payload: Record): boolean { + const callbacks = this.callbacks; + if (!callbacks) { + return false; + } + + switch (eventName) { + case 'agentic://session-created': + callbacks.onSessionCreated?.(payload as AgenticEvent); + break; + case 'agentic://session-deleted': + callbacks.onSessionDeleted?.(payload as AgenticEvent); + break; + case 'agentic://session-state-changed': + callbacks.onSessionStateChanged?.(payload as AgenticEvent); + break; + case 'agentic://image-analysis-started': + callbacks.onImageAnalysisStarted?.(payload as unknown as ImageAnalysisEvent); + break; + case 'agentic://image-analysis-completed': + callbacks.onImageAnalysisCompleted?.(payload as unknown as ImageAnalysisEvent); + break; + case 'agentic://dialog-turn-started': + callbacks.onDialogTurnStarted?.(payload as AgenticEvent); + break; + case 'agentic://model-round-started': + callbacks.onModelRoundStarted?.(payload as unknown as ModelRoundStartedEvent); + break; + case 'agentic://model-round-completed': + callbacks.onModelRoundCompleted?.(payload as unknown as ModelRoundCompletedEvent); + break; + case 'agentic://model-round-attempt-superseded': + callbacks.onModelRoundAttemptSuperseded?.( + payload as unknown as ModelRoundAttemptSupersededEvent, + ); + break; + case 'agentic://text-chunk': + callbacks.onTextChunk?.(payload as unknown as TextChunkEvent); + break; + case 'agentic://tool-event': + callbacks.onToolEvent?.(payload as unknown as ToolEvent); + break; + case 'agentic://subagent-session-linked': + callbacks.onSubagentSessionLinked?.(payload as unknown as SubagentSessionLinkedEvent); + break; + case 'agentic://deep-review-queue-state-changed': + callbacks.onDeepReviewQueueStateChanged?.( + payload as unknown as DeepReviewQueueStateChangedEvent, + ); + break; + case 'agentic://dialog-turn-completed': + callbacks.onDialogTurnCompleted?.(payload as AgenticEvent); + break; + case 'agentic://dialog-turn-failed': + callbacks.onDialogTurnFailed?.(payload as AgenticEvent); + break; + case 'agentic://dialog-turn-cancelled': + callbacks.onDialogTurnCancelled?.(payload as AgenticEvent); + break; + case 'agentic://token-usage-updated': + callbacks.onTokenUsageUpdated?.(payload as AgenticEvent); + break; + case 'agentic://acp-context-usage-updated': + callbacks.onAcpContextUsageUpdated?.(payload as unknown as AcpContextUsageUpdatedEvent); + break; + case 'agentic://context-compression-started': + callbacks.onContextCompressionStarted?.(payload as AgenticEvent); + break; + case 'agentic://context-compression-completed': + callbacks.onContextCompressionCompleted?.(payload as AgenticEvent); + break; + case 'agentic://context-compression-failed': + callbacks.onContextCompressionFailed?.(payload as AgenticEvent); + break; + case 'agentic://thread-goal-updated': + callbacks.onThreadGoalUpdated?.( + payload as { sessionId: string; goal?: Record | null }, + ); + break; + case 'agentic://open-built-in-browser': + callbacks.onOpenBuiltInBrowser?.(payload as unknown as OpenBuiltInBrowserEvent); + break; + case 'session_title_generated': + callbacks.onSessionTitleGenerated?.(payload as unknown as SessionTitleGeneratedEvent); + break; + case 'agentic://session-model-auto-migrated': + callbacks.onSessionModelAutoMigrated?.( + payload as unknown as SessionModelAutoMigratedEvent, + ); + break; + case 'agentic://user-steering-injected': + callbacks.onUserSteeringInjected?.(payload as unknown as UserSteeringInjectedEvent); + break; + default: + logger.debug('Ignoring unsupported external agentic event', { eventName }); + break; + } + return true; + } + async stopListening(): Promise { if (!this.isListening) { return; @@ -304,6 +414,7 @@ export class AgenticEventListener { this.unlistenFunctions = []; this.isListening = false; + this.callbacks = null; logger.info('Stopped all event listeners'); } diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts index da7f45d7d9..af4515382a 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts @@ -19,6 +19,10 @@ vi.mock('./flow-chat-manager/PeerSessionRefreshModule', () => ({ installPeerSessionRefresh: vi.fn(() => () => {}), })); +vi.mock('@/features/dispatch/DispatchJobObserver', () => ({ + installDispatchJobObserver: vi.fn(() => () => {}), +})); + vi.mock('../store/FlowChatStore', () => ({ FlowChatStore: { getInstance: () => storeMocks.store, diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 0ca9c4f200..de7aacd062 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -57,6 +57,7 @@ import { } from './flow-chat-manager'; import { ensureBackendSession } from './flow-chat-manager/SessionModule'; import { installPeerSessionRefresh } from './flow-chat-manager/PeerSessionRefreshModule'; +import { installDispatchJobObserver } from '@/features/dispatch/DispatchJobObserver'; const log = createLogger('FlowChatManager'); @@ -70,6 +71,7 @@ export class FlowChatManager { private initializationRequests = new Map>(); private latestInitializationRequestKey: string | null = null; private peerSessionRefreshCleanup: (() => void) | null = null; + private dispatchJobObserverCleanup: (() => void) | null = null; private disposed = false; private constructor() { @@ -99,6 +101,7 @@ export class FlowChatManager { this.agentService = AgentService.getInstance(); installPendingQueueDrainListener(this.context); this.peerSessionRefreshCleanup = installPeerSessionRefresh(this.context); + this.dispatchJobObserverCleanup = installDispatchJobObserver(this.context); } /** Public hook used by the queue panel "send now" fallback to drain head item. */ @@ -427,6 +430,8 @@ export class FlowChatManager { this.cleanupEventListeners(); this.peerSessionRefreshCleanup?.(); this.peerSessionRefreshCleanup = null; + this.dispatchJobObserverCleanup?.(); + this.dispatchJobObserverCleanup = null; this.context.eventBatcher.destroy(); } @@ -654,6 +659,8 @@ export class FlowChatManager { userMessageMetadata?: Record; turnId?: string; preserveTurnOnStartError?: boolean; + /** One-shot UI confirmation for unattended auto approval. */ + dispatchAutoConfirmed?: boolean; } ): Promise { const targetSessionId = sessionId || this.context.flowChatStore.getState().activeSessionId; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index 6c85714f12..53dbc5f97a 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -1,10 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { cancelSessionTask, syncSessionModelSelection } from './MessageModule'; +import { cancelSessionTask, sendMessage, syncSessionModelSelection } from './MessageModule'; import { SessionExecutionEvent } from '../../state-machine/types'; const mockTransition = vi.fn(); const mockUpdateSessionModel = vi.fn(); const mockGetConfigs = vi.fn(); +const mockGetCurrentState = vi.fn(() => 'processing'); +const mockDispatchSubmit = vi.fn(); +const mockDispatchProgress = vi.fn(); +const mockDispatchRefresh = vi.fn(); +const mockStartDialogTurn = vi.fn(); +const mockBindSession = vi.fn(); vi.mock('../../state-machine', () => ({ SessionExecutionEvent: { @@ -15,17 +21,49 @@ vi.mock('../../state-machine', () => ({ PROCESSING: 'processing', }, stateMachineManager: { - getCurrentState: vi.fn(() => 'processing'), + getCurrentState: () => mockGetCurrentState(), transition: (...args: any[]) => mockTransition(...args), }, })); vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ agentAPI: { + startDialogTurn: (...args: unknown[]) => mockStartDialogTurn(...args), updateSessionModel: (...args: unknown[]) => mockUpdateSessionModel(...args), }, })); +vi.mock('@/infrastructure/api/service-api/WorktreeAPI', () => ({ + worktreeAPI: { + bindSession: (...args: unknown[]) => mockBindSession(...args), + }, +})); + +vi.mock('@/features/dispatch/dispatchApi', () => ({ + dispatchApi: { + submit: (...args: unknown[]) => mockDispatchSubmit(...args), + }, +})); + +vi.mock('@/features/dispatch/dispatchJobStore', () => ({ + dispatchJobStore: { + getState: () => ({ + updateProgress: (...args: unknown[]) => mockDispatchProgress(...args), + }), + }, +})); + +vi.mock('@/features/dispatch/DispatchJobObserver', () => ({ + requestDispatchJobRefresh: (...args: unknown[]) => mockDispatchRefresh(...args), +})); + +vi.mock('./PendingQueueModule', () => ({ + pendingQueueManager: { + list: () => [], + enqueue: vi.fn(), + }, +})); + vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ ACPClientAPI: {}, })); @@ -45,6 +83,7 @@ vi.mock('../../../shared/notification-system', () => ({ describe('MessageModule cancellation', () => { beforeEach(() => { vi.clearAllMocks(); + mockGetCurrentState.mockReturnValue('processing'); mockTransition.mockResolvedValue(true); }); @@ -92,6 +131,106 @@ describe('MessageModule cancellation', () => { }); }); +describe('MessageModule detached dispatch', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetCurrentState.mockReturnValue('idle'); + mockDispatchSubmit.mockResolvedValue({ + accepted: true, + jobId: 'job-1', + sessionId: 'dispatch-session', + state: 'queued', + }); + }); + + function createDispatchContext(approvalPolicy: 'auto' | 'reject-and-report') { + const session = { + sessionId: 'dispatch-session', + title: 'New Chat', + titleStatus: 'generated', + mode: 'agentic', + dialogTurns: [], + config: { + modelName: 'controller-model', + dispatchTargetRequest: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target/repo', + }, + dispatchTarget: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target/repo', + displayName: 'build-host', + }, + dispatchJobId: 'job-1', + dispatchApprovalPolicy: approvalPolicy, + dispatchJobState: 'submitting', + dispatchCursor: 0, + }, + }; + return { + session, + context: { + flowChatStore: { + getState: () => ({ + activeSessionId: session.sessionId, + sessions: new Map([[session.sessionId, session]]), + }), + applyDispatchSnapshot: vi.fn(() => ({ applied: true, cursor: 0 })), + updateSessionLastSubmittedMode: vi.fn(), + updateSessionMode: vi.fn(), + }, + pendingHistoryLoads: new Map(), + } as any, + }; + } + + it('submits without controller model/title and bypasses local turn/worktree APIs', async () => { + const { context } = createDispatchContext('reject-and-report'); + + await sendMessage(context, 'run remote checks', 'dispatch-session'); + + expect(mockDispatchSubmit).toHaveBeenCalledWith({ + target: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target/repo', + }, + jobId: 'job-1', + sessionId: 'dispatch-session', + agentType: 'agentic', + prompt: 'run remote checks', + approvalPolicy: 'reject-and-report', + model: undefined, + }); + expect(mockStartDialogTurn).not.toHaveBeenCalled(); + expect(mockBindSession).not.toHaveBeenCalled(); + }); + + it('requires a one-shot auto-approval confirmation before the actual submit', async () => { + const { context } = createDispatchContext('auto'); + + await expect( + sendMessage(context, 'run remote checks', 'dispatch-session'), + ).rejects.toThrow('requires an explicit confirmation'); + expect(mockDispatchSubmit).not.toHaveBeenCalled(); + + await expect( + sendMessage( + context, + 'run remote checks', + 'dispatch-session', + undefined, + undefined, + undefined, + { dispatchAutoConfirmed: true }, + ), + ).resolves.toBeUndefined(); + expect(mockDispatchSubmit).toHaveBeenCalledTimes(1); + }); +}); + describe('MessageModule model synchronization', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index a3e8090b63..4b218172b2 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -25,6 +25,10 @@ import { import { pendingQueueManager } from './PendingQueueModule'; import { sessionProjectWorkspacePath } from '../../utils/sessionWorkspace'; import { sessionWorktreeMaterializationPlan } from '../../utils/sessionWorktree'; +import { dispatchApi } from '@/features/dispatch/dispatchApi'; +import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; +import { requestDispatchJobRefresh } from '@/features/dispatch/DispatchJobObserver'; +import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; const log = createLogger('MessageModule'); @@ -140,6 +144,8 @@ export async function sendMessage( userMessageMetadata?: Record; turnId?: string; preserveTurnOnStartError?: boolean; + /** One-shot UI confirmation for unattended auto approval. Never persist this flag. */ + dispatchAutoConfirmed?: boolean; } ): Promise { const session = context.flowChatStore.getState().sessions.get(sessionId); @@ -198,6 +204,7 @@ export async function sendMessage( const refreshedSession = context.flowChatStore.getState().sessions.get(sessionId) ?? session; const currentAgentType = (agentType?.trim() || refreshedSession.mode || 'agentic').trim(); const acpClientId = acpClientIdFromMode(currentAgentType); + const isDispatched = isNonLocalDispatchTarget(refreshedSession.config.dispatchTarget); if ( !acpClientId && @@ -211,7 +218,7 @@ export async function sendMessage( throw new Error('Session history is still restoring, please retry once loading finishes'); } - if (!acpClientId) { + if (!acpClientId && !isDispatched) { await ensureBackendSession(context, sessionId); } @@ -221,6 +228,62 @@ export async function sendMessage( } const isFirstMessage = readySession.dialogTurns.length === 0 && readySession.titleStatus !== 'generated'; + + if (isDispatched) { + const targetRequest = readySession.config.dispatchTargetRequest; + const jobId = readySession.config.dispatchJobId; + const approvalPolicy = readySession.config.dispatchApprovalPolicy; + if (!targetRequest || targetRequest.kind === 'local' || !jobId || !approvalPolicy) { + throw new Error('Dispatch session is missing its immutable target or approval policy'); + } + if (targetRequest.kind !== 'ssh') { + throw new Error('Phase-one dispatch supports SSH targets only'); + } + if ((options?.imageContexts?.length ?? 0) > 0) { + throw new Error('Image attachments are not supported for SSH dispatch yet'); + } + if (readySession.dialogTurns.length > 0) { + throw new Error('Phase-one dispatch sessions accept one detached task'); + } + if ( + readySession.config.dispatchJobState !== 'submitting' && + readySession.config.dispatchJobState !== 'submission_unknown' + ) { + throw new Error('This detached dispatch job has already been submitted'); + } + if (approvalPolicy === 'auto' && options?.dispatchAutoConfirmed !== true) { + throw new Error('Auto-approval dispatch requires an explicit confirmation before submit'); + } + if (isFirstMessage) { + handleTitleGeneration(context, sessionId, message); + } + + const response = await dispatchApi.submit({ + target: targetRequest, + jobId, + sessionId, + agentType: currentAgentType, + prompt: message, + approvalPolicy, + model: readySession.config.dispatchModel?.trim() || undefined, + }); + if (!response.accepted || response.jobId !== jobId || response.sessionId !== sessionId) { + throw new Error('Dispatch target returned a mismatched acknowledgement'); + } + context.flowChatStore.applyDispatchSnapshot(sessionId, { + jobId, + state: response.state, + cursor: readySession.config.dispatchCursor ?? 0, + expectedCursor: readySession.config.dispatchCursor ?? 0, + }); + dispatchJobStore.getState().updateProgress(jobId, { + state: response.state, + }); + context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); + requestDispatchJobRefresh(jobId); + return; + } + const dialogTurnId = options?.turnId?.trim() || `dialog_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const hasImages = (options?.imageContexts?.length ?? 0) > 0; @@ -443,6 +506,20 @@ export async function cancelSessionTask(context: FlowChatContext, requestedSessi return false; } + const session = state.sessions.get(sessionId); + if (isNonLocalDispatchTarget(session?.config.dispatchTarget)) { + const jobId = session?.config.dispatchJobId; + if (!jobId) { + return false; + } + const response = await dispatchApi.cancel(jobId); + if (response.cancelled) { + context.userCancelledSessionIds.add(sessionId); + requestDispatchJobRefresh(jobId); + } + return response.cancelled; + } + const currentState = stateMachineManager.getCurrentState(sessionId); const success = currentState === SessionExecutionState.PROCESSING ? await stateMachineManager.transition(sessionId, SessionExecutionEvent.USER_CANCEL) diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index fa74ad0af2..eb88a53319 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -21,6 +21,12 @@ function isTransientSession(session: { isTransient?: boolean } | undefined): boo return session?.isTransient === true; } +function isObserverOnlyDispatchSession( + session: { config?: { dispatchTarget?: { kind?: string } } } | undefined, +): boolean { + return !!session?.config?.dispatchTarget && session.config.dispatchTarget.kind !== 'local'; +} + function requireWorkspacePath(sessionId: string, workspacePath?: string): string { if (!workspacePath) { throw new Error(`Workspace path is required for session: ${sessionId}`); @@ -277,7 +283,7 @@ async function performSaveDialogTurnToDisk( log.debug('Session not found, skipping save', { sessionId, turnId }); return; } - if (isTransientSession(session)) { + if (isTransientSession(session) || isObserverOnlyDispatchSession(session)) { return; } @@ -315,7 +321,7 @@ export async function saveAllInProgressTurns(context: FlowChatContext): Promise< const savePromises: Promise[] = []; for (const [sessionId, session] of state.sessions.entries()) { - if (isTransientSession(session)) { + if (isTransientSession(session) || isObserverOnlyDispatchSession(session)) { continue; } const lastTurn = session.dialogTurns[session.dialogTurns.length - 1]; @@ -520,7 +526,7 @@ export async function updateSessionMetadata( const session = context.flowChatStore.getState().sessions.get(sessionId); if (!session) return; - if (isTransientSession(session)) return; + if (isTransientSession(session) || isObserverOnlyDispatchSession(session)) return; const workspacePath = requireSessionProjectWorkspacePath(session, sessionId); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index a844f4d48f..20e1d9d229 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -46,6 +46,12 @@ const stateMachineMocks = vi.hoisted(() => ({ delete: vi.fn(), })); +const dispatchStoreMocks = vi.hoisted(() => ({ + registerJob: vi.fn(), + dismissJob: vi.fn(), + updateTitle: vi.fn(), +})); + vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ agentAPI: agentApiMocks, })); @@ -97,6 +103,12 @@ vi.mock('../../state-machine', () => ({ stateMachineManager: stateMachineMocks, })); +vi.mock('@/features/dispatch/dispatchJobStore', () => ({ + dispatchJobStore: { + getState: () => dispatchStoreMocks, + }, +})); + function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -401,6 +413,61 @@ describe('createChatSession', () => { expect.any(Object), ); }); + + it('creates an observer projection without a local model or backend session', async () => { + configManagerMocks.getConfigs.mockRejectedValue( + new Error('No controller-side model is configured'), + ); + const { context, flowChatStore } = createContext(createSession({ + workspacePath: '/source/repo', + })); + + const sessionId = await createChatSession(context, { + workspacePath: '/source/repo', + dispatchTargetRequest: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target/repo', + }, + dispatchTarget: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target/repo', + displayName: 'build-host', + }, + dispatchApprovalPolicy: 'reject-and-report', + }, 'agentic'); + + expect(sessionId).toEqual(expect.any(String)); + expect(agentApiMocks.createSession).not.toHaveBeenCalled(); + expect(flowChatStore.createSession).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ + modelName: undefined, + dispatchJobId: expect.any(String), + dispatchApprovalPolicy: 'reject-and-report', + dispatchJobState: 'submitting', + dispatchCursor: 0, + }), + undefined, + expect.any(String), + 128128, + 'agentic', + '/source/repo', + undefined, + undefined, + expect.any(Object), + ); + expect(dispatchStoreMocks.registerJob).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId, + state: 'submitting', + approvalPolicy: 'reject-and-report', + model: undefined, + }), + ); + expect(configManagerMocks.getConfigs).not.toHaveBeenCalled(); + }); }); describe('SessionModule historical session coordination', () => { @@ -928,6 +995,34 @@ describe('SessionModule historical session coordination', () => { expect(persistenceMocks.cleanupSaveState).toHaveBeenCalledWith(context, 'active-1'); }); + it('tombstones a deleted dispatch projection instead of deleting a local session', async () => { + const session = createSession({ + sessionId: 'dispatch-session', + isHistorical: false, + config: { + dispatchTarget: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target/repo', + displayName: 'build-host', + }, + dispatchJobId: 'job-1', + }, + }); + const { context, flowChatStore } = createContext(session, { + activeSessionId: session.sessionId, + }); + + await deleteChatSession(context, session.sessionId); + + expect(dispatchStoreMocks.dismissJob).toHaveBeenCalledWith('job-1'); + expect(flowChatStore.removeSession).toHaveBeenCalledWith( + session.sessionId, + { nextActiveSessionId: null }, + ); + expect(flowChatStore.deleteSession).not.toHaveBeenCalled(); + }); + it('returns to the welcome state after deleting a non-empty active session', async () => { const activeSession = createSession({ sessionId: 'active-1', diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index fd842eae4b..3bd4e7801f 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -48,9 +48,15 @@ import { requireSessionProjectWorkspacePath, sessionProjectWorkspacePath, } from '../../utils/sessionWorkspace'; +import { + isNonLocalDispatchTarget, + type DispatchTarget, +} from '@/features/dispatch/types'; +import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; const log = createLogger('SessionModule'); const pendingSessionCreations = new Map>(); +const DISPATCH_OBSERVER_MAX_CONTEXT_TOKENS = 128128; const getHydrationLocationKey = ( location: SessionHistoryHydrationLocation | undefined, @@ -663,6 +669,7 @@ export async function createChatSession( workspaceCreationKey, agentType, config.executionTargetRequest ?? { kind: 'local' }, + config.dispatchTargetRequest ?? { kind: 'local' }, ]); const pendingCreation = pendingSessionCreations.get(creationKey); @@ -690,9 +697,84 @@ export async function createChatSession( ); const sessionName = titleDescriptor.text; + if (isNonLocalDispatchTarget(config.dispatchTargetRequest)) { + const dispatchTarget: DispatchTarget = config.dispatchTarget + ?? ( + config.dispatchTargetRequest.kind === 'ssh' + ? { + ...config.dispatchTargetRequest, + displayName: config.dispatchTargetRequest.connectionId, + } + : { + ...config.dispatchTargetRequest, + displayName: config.dispatchTargetRequest.deviceId, + } + ); + const sessionId = + globalThis.crypto?.randomUUID?.() + ?? `dispatch-session-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const jobId = + config.dispatchJobId?.trim() + || `dispatch-${globalThis.crypto?.randomUUID?.() + ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`; + const approvalPolicy = config.dispatchApprovalPolicy; + if (!approvalPolicy) { + throw new Error('Dispatch approval policy must be selected before creating a session'); + } + const resolvedConfig: SessionConfig = { + ...config, + // A dispatch projection must not inherit or resolve a controller-side + // provider. The target selection, when explicit, lives in dispatchModel. + modelName: undefined, + workspaceId: workspace?.id ?? config.workspaceId, + workspacePath, + projectWorkspacePath, + dispatchTargetRequest: config.dispatchTargetRequest, + dispatchTarget, + dispatchJobId: jobId, + dispatchApprovalPolicy: approvalPolicy, + dispatchJobState: 'submitting', + dispatchCursor: 0, + }; + + // This is an observer projection only. In particular, do not call + // agentAPI.createSession: the target CLI owns the durable session. + context.flowChatStore.createSession( + sessionId, + resolvedConfig, + undefined, + sessionName, + DISPATCH_OBSERVER_MAX_CONTEXT_TOKENS, + agentType, + workspacePath, + remoteConnectionId, + remoteSshHost, + titleDescriptor, + ); + dispatchJobStore.getState().registerJob({ + jobId, + sessionId, + targetRequest: config.dispatchTargetRequest, + target: dispatchTarget, + sourceWorkspacePath: workspacePath, + sourceWorkspaceId: resolvedConfig.workspaceId, + title: sessionName, + agentType, + approvalPolicy, + // Do not inherit the controller's model selector. An omitted target + // model lets the probed target use its own configured default. + model: config.dispatchModel?.trim() || undefined, + cursor: 0, + state: 'submitting', + appliedEventIds: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }); + return sessionId; + } + const sessionModelName = await resolveModelForSessionCreation(config.modelName); const maxContextTokens = await getModelMaxTokens(sessionModelName, agentType); - const mergedConfig: SessionConfig = { ...config, modelName: sessionModelName, @@ -793,6 +875,9 @@ export async function switchChatSession( }); const touchActiveSessionInBackground = () => { + if (isNonLocalDispatchTarget(session?.config.dispatchTarget)) { + return; + } scheduleSessionActivityTouch(() => { const latestState = context.flowChatStore.getState(); const latestSession = latestState.sessions.get(sessionId); @@ -896,6 +981,22 @@ export async function deleteChatSession( stateBeforeDelete.activeSessionId && removedSessionIdSet.has(stateBeforeDelete.activeSessionId) ); + const session = stateBeforeDelete.sessions.get(sessionId); + if (isNonLocalDispatchTarget(session?.config.dispatchTarget)) { + if (session?.config.dispatchJobId) { + dispatchJobStore.getState().dismissJob(session.config.dispatchJobId); + } + context.flowChatStore.removeSession( + sessionId, + removedActiveSession ? { nextActiveSessionId: null } : undefined, + ); + removedSessionIds.forEach(id => { + context.processingManager.clearSessionStatus(id); + cleanupSaveState(context, id); + cleanupSessionBuffers(context, id); + }); + return; + } await context.flowChatStore.deleteSession( sessionId, removedActiveSession ? { nextActiveSessionId: null } : undefined, @@ -932,6 +1033,22 @@ export async function archiveChatSession( && removedSessionIdSet.has(stateBeforeArchive.activeSessionId) ); + if (isNonLocalDispatchTarget(session.config.dispatchTarget)) { + if (session.config.dispatchJobId) { + dispatchJobStore.getState().dismissJob(session.config.dispatchJobId); + } + context.flowChatStore.removeSession( + sessionId, + removedActiveSession ? { nextActiveSessionId: null } : undefined, + ); + removedSessionIds.forEach(id => { + context.processingManager.clearSessionStatus(id); + cleanupSaveState(context, id); + cleanupSessionBuffers(context, id); + }); + return; + } + await sessionAPI.archiveSession( sessionId, requireSessionProjectWorkspacePath(session, sessionId), @@ -978,6 +1095,13 @@ export async function renameChatSessionTitle( await context.flowChatStore.updateSessionTitle(sessionId, trimmedTitle, 'generated'); return trimmedTitle; } + if (isNonLocalDispatchTarget(session.config.dispatchTarget)) { + await context.flowChatStore.updateSessionTitle(sessionId, trimmedTitle, 'generated'); + if (session.config.dispatchJobId) { + dispatchJobStore.getState().updateTitle(session.config.dispatchJobId, trimmedTitle); + } + return trimmedTitle; + } const updatedTitle = await agentAPI.updateSessionTitle({ sessionId, @@ -1000,6 +1124,9 @@ export async function forkChatSession( if (!sourceSession) { throw new Error(`Session does not exist: ${sourceSessionId}`); } + if (isNonLocalDispatchTarget(sourceSession.config.dispatchTarget)) { + throw new Error('Forking a dispatched session is not supported in phase one'); + } const executionWorkspacePath = requireSessionWorkspacePath( sourceSession.workspacePath, @@ -1070,6 +1197,9 @@ export async function ensureBackendSession( if (session.isTransient) { return; } + if (isNonLocalDispatchTarget(session.config.dispatchTarget)) { + return; + } if (session.isHistorical) { await hydrateHistoricalSession(context, sessionId, false); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 27971e0b8a..1829d75b7e 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -14,6 +14,8 @@ const apiMocks = vi.hoisted(() => ({ restoreSessionView: vi.fn(), restoreSessionWithTurns: vi.fn(), accountFetchSessionTurns: vi.fn(), + cancelSession: vi.fn(), + cancelDispatchJob: vi.fn(), })); const peerModeFlagMock = vi.hoisted(() => ({ active: false })); @@ -63,6 +65,7 @@ vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ agentAPI: { + cancelSession: apiMocks.cancelSession, deleteSession: apiMocks.deleteSession, restoreSession: apiMocks.restoreSession, get restoreSessionView() { @@ -72,6 +75,12 @@ vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ }, })); +vi.mock('@/features/dispatch/dispatchApi', () => ({ + dispatchApi: { + cancel: apiMocks.cancelDispatchJob, + }, +})); + vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', () => ({ remoteConnectAPI: { accountFetchSessionTurns: apiMocks.accountFetchSessionTurns, @@ -214,6 +223,141 @@ describe('FlowChatStore lazy worktree preference', () => { }); }); +describe('FlowChatStore dispatch observer boundaries', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiMocks.cancelDispatchJob.mockResolvedValue({ cancelled: true }); + }); + + afterEach(() => { + resetStore(); + }); + + it('leaves a detached target running when its source workspace closes', async () => { + const session = createSession({ + workspacePath: '/source', + config: { + workspaceId: 'workspace-1', + dispatchTarget: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target', + displayName: 'build-host', + }, + dispatchJobId: 'job-1', + dispatchJobState: 'running', + }, + dialogTurns: [], + }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + await expect(flowChatStore.cancelRunningSessionsForWorkspace({ + id: 'workspace-1', + rootPath: '/source', + connectionId: undefined, + sshHost: undefined, + })).resolves.toEqual([]); + + expect(apiMocks.cancelDispatchJob).not.toHaveBeenCalled(); + expect(apiMocks.cancelSession).not.toHaveBeenCalled(); + }); + + it('never saves a locally-cancelled observer turn into the session store', async () => { + const session = createSession({ + workspacePath: '/source', + config: { + dispatchTarget: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target', + displayName: 'build-host', + }, + dispatchJobId: 'job-1', + dispatchJobState: 'running', + }, + dialogTurns: [{ + id: 'turn-1', + sessionId: 'session-1', + userMessage: { + id: 'user-1', + content: 'run task', + timestamp: 1, + }, + modelRounds: [], + status: 'processing', + startTime: 1, + }], + }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + await (flowChatStore as any).saveCancelledDialogTurn( + session.sessionId, + 'turn-1', + ); + + expect(apiMocks.saveSessionTurn).not.toHaveBeenCalled(); + }); + + it('keeps an existing failed terminal outcome when a stale cancelled snapshot arrives', () => { + const terminalTurn = { + id: 'turn-1', + sessionId: 'session-1', + userMessage: { + id: 'user-1', + content: 'run task', + timestamp: 1, + }, + modelRounds: [], + status: 'error' as const, + error: 'Target execution failed', + startTime: 1, + endTime: 2, + }; + const session = createSession({ + workspacePath: '/source', + config: { + dispatchTarget: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target', + displayName: 'build-host', + }, + dispatchJobId: 'job-1', + dispatchJobState: 'failed', + dispatchCursor: 10, + dispatchLastError: 'Target execution failed', + }, + error: 'Target execution failed', + dialogTurns: [terminalTurn], + }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + const result = flowChatStore.applyDispatchSnapshot(session.sessionId, { + jobId: 'job-1', + state: 'cancelled', + cursor: 10, + expectedCursor: 10, + terminalDrained: true, + }); + const appliedSession = flowChatStore.getState().sessions.get(session.sessionId)!; + + expect(result).toEqual({ applied: true, cursor: 10 }); + expect(appliedSession.config.dispatchJobState).toBe('failed'); + expect(appliedSession.config.dispatchLastError).toBe('Target execution failed'); + expect(appliedSession.error).toBe('Target execution failed'); + expect(appliedSession.dialogTurns[0]).toBe(terminalTurn); + }); +}); + describe('FlowChatStore metadata persistence callbacks', () => { afterEach(() => { resetStore(); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 6e7de80af4..9a4a6e9d8d 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -57,6 +57,7 @@ import { normalizeRecoveredToolStatus, normalizeRecoveredTurnFinishReason, normalizeRecoveredTurnStatus, + settleDialogTurnToTerminalStatus, settleInterruptedDialogTurn, } from '../utils/dialogTurnStability'; import type { WorkspaceInfo } from '@/shared/types'; @@ -67,6 +68,10 @@ import { cleanRemoteUserInput } from '../utils/userInputText'; import { useBackgroundSubagentActivityStore } from './backgroundSubagentActivityStore'; import { sessionComposerStore } from './sessionComposerStore'; import { recordHistorySessionDiagnosticEvent } from '../services/historySessionDiagnostics'; +import { + isDispatchJobTerminal, + isNonLocalDispatchTarget, +} from '@/features/dispatch/types'; const log = createLogger('FlowChatStore'); const VALID_AGENT_TYPES = new Set([ @@ -99,6 +104,20 @@ export interface PeerSessionSnapshotRefreshResult { latestTurnStatus?: DialogTurn['status']; } +export interface DispatchSnapshotApplyResult { + applied: boolean; + cursor: number; +} + +function dispatchTerminalTurnStatus( + state: NonNullable, +): 'completed' | 'cancelled' | 'error' | null { + if (state === 'succeeded') return 'completed'; + if (state === 'cancelled') return 'cancelled'; + if (state === 'failed') return 'error'; + return null; +} + export function isBackendSessionActivelyProcessing(state: unknown): boolean { if (typeof state !== 'string') { return false; @@ -2152,6 +2171,156 @@ export class FlowChatStore { }); } + /** + * Bind an observer-only session to its immutable dispatch target. + * This updates frontend state only; it must never create a local runtime + * session or write the normal session store. + */ + public updateSessionDispatchTarget( + sessionId: string, + binding: { + targetRequest: NonNullable; + target: NonNullable; + jobId: string; + approvalPolicy: NonNullable; + state?: NonNullable; + cursor?: number; + }, + ): void { + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if (!session) return prev; + + const currentTarget = session.config.dispatchTarget; + if ( + currentTarget && + currentTarget.kind !== 'local' && + JSON.stringify(currentTarget) !== JSON.stringify(binding.target) + ) { + log.warn('Ignoring dispatch target mutation for an existing observer session', { + sessionId, + currentTarget, + requestedTarget: binding.target, + }); + return prev; + } + + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, { + ...session, + config: { + ...session.config, + dispatchTargetRequest: binding.targetRequest, + dispatchTarget: binding.target, + dispatchJobId: binding.jobId, + dispatchApprovalPolicy: binding.approvalPolicy, + dispatchJobState: binding.state ?? session.config.dispatchJobState ?? 'queued', + dispatchCursor: Math.max(0, binding.cursor ?? session.config.dispatchCursor ?? 0), + }, + lastActiveAt: Date.now(), + }); + return { ...prev, sessions: newSessions }; + }); + } + + /** + * Commit a target-side status snapshot only when it still follows the cursor + * that was polled. The observer applies all events first, then calls this + * method; a stale response therefore cannot jump the durable cursor forward. + */ + public applyDispatchSnapshot( + sessionId: string, + snapshot: { + jobId: string; + state: NonNullable; + cursor: number; + lastError?: string; + expectedCursor?: number; + cursorReset?: boolean; + /** + * True only after the observer receives an empty terminal page at the + * same cursor. Earlier terminal pages may still have projected events. + */ + terminalDrained?: boolean; + }, + ): DispatchSnapshotApplyResult { + let result: DispatchSnapshotApplyResult = { + applied: false, + cursor: this.state.sessions.get(sessionId)?.config.dispatchCursor ?? 0, + }; + + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if ( + !session || + session.config.dispatchJobId !== snapshot.jobId || + !session.config.dispatchTarget || + session.config.dispatchTarget.kind === 'local' + ) { + return prev; + } + const currentCursor = session.config.dispatchCursor ?? 0; + if ( + (!snapshot.cursorReset && snapshot.cursor < currentCursor) || + ( + snapshot.expectedCursor !== undefined && + snapshot.expectedCursor !== currentCursor + ) + ) { + result = { applied: false, cursor: currentCursor }; + return prev; + } + + const effectiveState = isDispatchJobTerminal(session.config.dispatchJobState) + ? session.config.dispatchJobState! + : snapshot.state; + const terminal = isDispatchJobTerminal(effectiveState); + const settledAt = Date.now(); + const terminalTurnStatus = snapshot.terminalDrained + ? dispatchTerminalTurnStatus(effectiveState) + : null; + let dialogTurns = session.dialogTurns; + const lastTurn = dialogTurns[dialogTurns.length - 1]; + if (terminalTurnStatus && lastTurn) { + const settledTurn = settleDialogTurnToTerminalStatus( + lastTurn, + terminalTurnStatus, + settledAt, + terminalTurnStatus === 'error' + ? snapshot.lastError || session.error || 'Dispatched task failed' + : undefined, + ); + if (settledTurn !== lastTurn) { + dialogTurns = [...dialogTurns.slice(0, -1), settledTurn]; + } + } + const terminalError = effectiveState === 'failed' + ? snapshot.lastError || session.error || 'Dispatched task failed' + : session.error; + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, { + ...session, + dialogTurns, + error: terminalError, + lastActiveAt: settledAt, + lastFinishedAt: terminal + ? session.lastFinishedAt ?? settledAt + : session.lastFinishedAt, + config: { + ...session.config, + dispatchJobState: effectiveState, + dispatchCursor: snapshot.cursor, + dispatchLastError: + snapshot.lastError ?? session.config.dispatchLastError, + }, + }); + result = { applied: true, cursor: snapshot.cursor }; + return { ...prev, sessions: newSessions }; + }); + + return result; + } + /** * Record an empty session's desired isolation state without touching Git. * MessageModule materializes this preference only after the user submits the @@ -2531,16 +2700,21 @@ export class FlowChatStore { public async cancelRunningSessionsForWorkspace( workspace: Pick ): Promise { - const runningSessionIds = Array.from(this.state.sessions.values()) + const runningSessions = Array.from(this.state.sessions.values()) .filter(session => sessionMatchesWorkspace(session, workspace)) .filter(session => { + if (isNonLocalDispatchTarget(session.config.dispatchTarget)) { + // Closing the source workspace must not stop a detached target job. + // Only the explicit task Stop action owns dispatch cancellation. + return false; + } const lastTurn = session.dialogTurns[session.dialogTurns.length - 1]; return Boolean( lastTurn && !['completed', 'cancelled', 'error'].includes(lastTurn.status) ); - }) - .map(session => session.sessionId); + }); + const runningSessionIds = runningSessions.map(session => session.sessionId); if (runningSessionIds.length === 0) { return []; @@ -2548,7 +2722,8 @@ export class FlowChatStore { const { agentAPI } = await import('@/infrastructure/api/service-api/AgentAPI'); await Promise.allSettled( - runningSessionIds.map(async sessionId => { + runningSessions.map(async session => { + const sessionId = session.sessionId; try { await agentAPI.cancelSession(sessionId); } catch (error) { @@ -3555,6 +3730,9 @@ export class FlowChatStore { if (session.isTransient) { return; } + if (isNonLocalDispatchTarget(session.config.dispatchTarget)) { + return; + } const workspacePath = sessionProjectWorkspacePath(session); if (!workspacePath) { diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 67fe6b37fc..cce01983fd 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -502,6 +502,22 @@ export interface SessionConfig { executionTargetRequest?: import('@/infrastructure/api/service-api/WorktreeAPI').SessionExecutionTargetRequest; /** Resolved target returned and persisted by the backend. */ executionTarget?: import('@/infrastructure/api/service-api/WorktreeAPI').SessionExecutionTarget; + /** Requested device on which a new session will execute. */ + dispatchTargetRequest?: import('@/features/dispatch/types').DispatchTargetRequest; + /** Immutable resolved target for an observer-only dispatched session. */ + dispatchTarget?: import('@/features/dispatch/types').DispatchTarget; + /** Durable target-side job observed by this projection. */ + dispatchJobId?: string; + /** Explicit unattended permission behavior selected before submission. */ + dispatchApprovalPolicy?: import('@/features/dispatch/types').DispatchApprovalPolicy; + /** Target model explicitly selected during preflight; omitted to use the target default. */ + dispatchModel?: string; + /** Last target-side job state applied by the observer. */ + dispatchJobState?: import('@/features/dispatch/types').DispatchJobState; + /** Byte cursor applied successfully from the target-side event log. */ + dispatchCursor?: number; + /** Last target-side job error, if any. */ + dispatchLastError?: string; /** * Composer-only preference for an empty session. The concrete worktree is * materialized after the first prompt is submitted, not when the checkbox diff --git a/src/web-ui/src/flow_chat/utils/dialogTurnStability.ts b/src/web-ui/src/flow_chat/utils/dialogTurnStability.ts index 1772d5916f..8b04a70689 100644 --- a/src/web-ui/src/flow_chat/utils/dialogTurnStability.ts +++ b/src/web-ui/src/flow_chat/utils/dialogTurnStability.ts @@ -29,6 +29,11 @@ type SettleInterruptedDialogTurnOptions = { interruptionReason?: FlowToolItem['interruptionReason']; }; +export type TerminalDialogTurnStatus = Extract< + DialogTurn['status'], + 'completed' | 'cancelled' | 'error' +>; + export function isTransientToolStatus(status: unknown): boolean { return typeof status === 'string' && TRANSIENT_TOOL_STATUSES.has(status); } @@ -259,3 +264,80 @@ export function settleInterruptedDialogTurn( return settledTurn; } + +/** + * Settle a live turn from an authoritative terminal snapshot. If a terminal + * agent event already settled the turn, preserve that event-owned outcome and + * return the original object once all nested content is stable. + */ +export function settleDialogTurnToTerminalStatus( + dialogTurn: DialogTurn, + requestedStatus: TerminalDialogTurnStatus, + settledAt: number, + error?: string, +): DialogTurn { + const alreadyTerminal = isTerminalTurnStatus(dialogTurn.status); + const finalTurnStatus = alreadyTerminal ? dialogTurn.status : requestedStatus; + const normalizedError = error?.trim() || undefined; + const nextError = finalTurnStatus === 'error' + ? normalizedError || dialogTurn.error + : dialogTurn.error; + const stableContent = dialogTurn.modelRounds.every(round => + TERMINAL_ROUND_STATUSES.has(round.status) && + round.items.every(item => STABLE_ITEM_STATUSES.has(item.status)) && + (round.attempts ?? []).every(attempt => + attempt.status !== 'streaming' && + attempt.items.every(item => STABLE_ITEM_STATUSES.has(item.status)) + ) + ); + + if ( + alreadyTerminal && + stableContent && + dialogTurn.endTime !== undefined && + nextError === dialogTurn.error + ) { + return dialogTurn; + } + + const attemptStatus = finalTurnStatus === 'completed' + ? 'completed' as const + : finalTurnStatus === 'error' + ? 'failed' as const + : 'cancelled' as const; + const modelRounds = dialogTurn.modelRounds.map(round => { + const finalRoundStatus = normalizeRecoveredRoundStatus(round.status, finalTurnStatus); + return { + ...round, + status: finalRoundStatus, + isStreaming: false, + isComplete: true, + endTime: round.endTime ?? settledAt, + items: round.items.map(item => + settleInterruptedItem(item, finalTurnStatus, settledAt), + ), + attempts: round.attempts?.map(attempt => ({ + ...attempt, + status: attempt.status === 'streaming' ? attemptStatus : attempt.status, + items: attempt.items.map(item => + settleInterruptedItem(item, finalTurnStatus, settledAt), + ), + })), + }; + }); + + return { + ...dialogTurn, + status: finalTurnStatus, + success: alreadyTerminal + ? dialogTurn.success + : finalTurnStatus === 'completed' + ? true + : finalTurnStatus === 'error' + ? false + : dialogTurn.success, + error: nextError, + endTime: dialogTurn.endTime ?? settledAt, + modelRounds, + }; +} diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index f1f54b1345..c458bf7a67 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -58,6 +58,16 @@ const LOCAL_ONLY_COMMANDS = new Set([ 'peer_controller_set_active', 'computer_use_request_permissions', 'computer_use_open_system_settings', + // Detached dispatch uses this controller's SSH credentials and observer index. + 'dispatch_list_targets', + 'dispatch_probe_target', + 'dispatch_install_cli_start', + 'dispatch_install_cli_poll', + 'dispatch_install_cli_cancel', + 'dispatch_submit', + 'dispatch_status', + 'dispatch_cancel', + 'dispatch_list_jobs', 'remote_connect_get_device_info', 'remote_connect_get_lan_ip', 'remote_connect_get_lan_network_info', diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index d2b58e7309..5b60446e91 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -157,6 +157,19 @@ }, "sessions": { "newSession": "New session", + "dispatchRunningOn": "Runs on {{target}} · {{state}}", + "dispatchUnreachable": "Target unreachable", + "dispatchUnreachableDetails": "Target unreachable: {{target}} · {{error}}", + "dispatchTransportErrorFallback": "Transport request failed", + "dispatchStates": { + "submitting": "submitting", + "submission_unknown": "checking submission", + "queued": "queued", + "running": "$t(shared:statuses.running)", + "succeeded": "$t(shared:statuses.done)", + "failed": "$t(shared:statuses.failed)", + "cancelled": "$t(shared:statuses.cancelled)" + }, "newCodeSession": "New Code session", "newCoworkSession": "New Cowork session", "newExternalAgentSessionShort": "{{agentName}} Session", @@ -1422,6 +1435,49 @@ "daysAgo": "{{count}}d ago" } }, + "dispatch": { + "configureTitle": "Prepare {{target}}", + "workspacePath": "Target workspace", + "workspacePlaceholder": "/path/to/project", + "check": "Check", + "cliStatus": "BitFun CLI", + "cliReady": "Ready ({{version}})", + "cliMissing": "Not installed or unreachable", + "cliIncompatible": "Update required: {{details}}", + "protocolVersionMismatch": "protocol {{actual}}; expected {{expected}}", + "workspaceStatus": "$t(shared:features.workspace)", + "workspaceGit": "{{branch}} · {{dirty}}", + "workspaceDirectory": "Directory is ready (not a Git repository)", + "workspaceMissing": "Path does not exist or is not a directory", + "unknownBranch": "unknown branch", + "dirty": "uncommitted changes", + "clean": "clean", + "upstreamStatus": "Upstream", + "upstreamCounts": "{{ahead}} ahead · {{behind}} behind", + "modelStatus": "Target model", + "modelReady": "Ready ({{model}})", + "modelAutomatic": "target default", + "modelMissing": "No usable model is configured on the target", + "installRequired": "Install or update BitFun CLI", + "installDescription": "BitFun will install the verified release in the target user's home directory.", + "version": "Version", + "downloadUrl": "Download", + "installConfirmTitle": "Install BitFun CLI on this target?", + "installConfirmMessage": "Download version {{version}} from {{url}} and verify SHA256 {{sha256}} before installation.", + "installConfirm": "Install", + "installing": "Installing…", + "installFailed": "CLI installation failed. Review the output and try again.", + "installOutput": "CLI installation output", + "installWaiting": "Waiting for installation output…", + "approvalTitle": "Unattended permission policy", + "approvalHint": "Choose explicitly. This applies only to this dispatched task.", + "approvalReject": "Reject and report", + "approvalRejectDescription": "Reject actions that require confirmation and report them in the transcript.", + "approvalAuto": "Auto approve", + "approvalAutoDescription": "Automatically approve permission requests on the target. You will confirm again before sending.", + "useTarget": "Use this target", + "cancel": "Cancel" + }, "collapse": "Collapse", "expand": "Expand", "retry": "Retry" diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 4efd58db2c..2f3df82811 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -626,6 +626,26 @@ "processingFailed": "Processing failed" }, "chatInput": { + "dispatch": { + "local": "This computer", + "locked": "Runs on {{target}} (fixed for this task)", + "current": "Run this task on {{target}}", + "menuLabel": "Where this task runs", + "sessionScope": "New task", + "localSection": "Local", + "localDescription": "Run in this BitFun app", + "sshSection": "SSH", + "loading": "Loading targets…", + "noSshTargets": "No saved SSH targets", + "sshDescription": "Run through a saved SSH connection", + "addSsh": "Add SSH connection…", + "createFailed": "Could not create the dispatched task.", + "remoteTarget": "the remote target", + "autoConfirmTitle": "Allow unattended changes?", + "autoConfirmMessage": "This task will run on {{target}} and automatically approve permission requests. Continue?", + "autoConfirmAction": "Dispatch with auto approval", + "autoConfirmCancel": "Cancel" + }, "addBoostTooltip": "Agent modes, image, or skills", "permissionMode": { "menuLabel": "Permission mode", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 0155baee55..34721a6bd8 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -157,6 +157,19 @@ }, "sessions": { "newSession": "新建会话", + "dispatchRunningOn": "运行在 {{target}} · {{state}}", + "dispatchUnreachable": "目标不可达", + "dispatchUnreachableDetails": "目标不可达:{{target}} · {{error}}", + "dispatchTransportErrorFallback": "传输请求失败", + "dispatchStates": { + "submitting": "待提交", + "submission_unknown": "正在确认提交状态", + "queued": "排队中", + "running": "$t(shared:statuses.running)", + "succeeded": "$t(shared:statuses.done)", + "failed": "$t(shared:statuses.failed)", + "cancelled": "$t(shared:statuses.cancelled)" + }, "newCodeSession": "新建 Code 会话", "newCoworkSession": "新建 Cowork 会话", "newExternalAgentSessionShort": "{{agentName}} 会话", @@ -1422,6 +1435,49 @@ "daysAgo": "{{count}} 天前" } }, + "dispatch": { + "configureTitle": "准备 {{target}}", + "workspacePath": "目标工作区", + "workspacePlaceholder": "/项目/路径", + "check": "检查", + "cliStatus": "BitFun CLI", + "cliReady": "就绪({{version}})", + "cliMissing": "未安装或无法连接", + "cliIncompatible": "需要更新:{{details}}", + "protocolVersionMismatch": "协议版本 {{actual}},需要 {{expected}}", + "workspaceStatus": "$t(shared:features.workspace)", + "workspaceGit": "{{branch}} · {{dirty}}", + "workspaceDirectory": "目录可用(不是 Git 仓库)", + "workspaceMissing": "路径不存在或不是目录", + "unknownBranch": "未知分支", + "dirty": "有未提交更改", + "clean": "干净", + "upstreamStatus": "上游", + "upstreamCounts": "领先 {{ahead}} · 落后 {{behind}}", + "modelStatus": "目标模型", + "modelReady": "就绪({{model}})", + "modelAutomatic": "目标默认模型", + "modelMissing": "目标上没有可用的模型配置", + "installRequired": "安装或更新 BitFun CLI", + "installDescription": "BitFun 会将已验证的发行版安装到目标用户的主目录。", + "version": "版本", + "downloadUrl": "下载地址", + "installConfirmTitle": "在此目标上安装 BitFun CLI?", + "installConfirmMessage": "将从 {{url}} 下载版本 {{version}},安装前校验 SHA256 {{sha256}}。", + "installConfirm": "安装", + "installing": "正在安装…", + "installFailed": "CLI 安装失败。请检查输出后重试。", + "installOutput": "CLI 安装输出", + "installWaiting": "正在等待安装输出…", + "approvalTitle": "无人值守权限策略", + "approvalHint": "必须明确选择,仅对这个派发任务生效。", + "approvalReject": "拒绝并报告", + "approvalRejectDescription": "拒绝需要确认的操作,并在会话记录中报告。", + "approvalAuto": "自动批准", + "approvalAutoDescription": "自动批准目标上的权限请求。发送前仍会再次确认。", + "useTarget": "使用此目标", + "cancel": "取消" + }, "collapse": "折叠", "expand": "展开", "retry": "重试" diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 40669ed696..fc37bb3c14 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -626,6 +626,26 @@ "processingFailed": "处理失败" }, "chatInput": { + "dispatch": { + "local": "本机", + "locked": "运行在 {{target}}(此任务不可更改)", + "current": "将此任务运行在 {{target}}", + "menuLabel": "这个任务在哪运行", + "sessionScope": "新任务", + "localSection": "本机", + "localDescription": "在当前 BitFun 应用中运行", + "sshSection": "SSH", + "loading": "正在加载目标…", + "noSshTargets": "没有已保存的 SSH 目标", + "sshDescription": "通过已保存的 SSH 连接运行", + "addSsh": "添加 SSH 连接…", + "createFailed": "无法创建派发任务。", + "remoteTarget": "远程目标", + "autoConfirmTitle": "允许无人值守地修改吗?", + "autoConfirmMessage": "此任务将在 {{target}} 上运行,并自动批准权限请求。是否继续?", + "autoConfirmAction": "自动批准并派发", + "autoConfirmCancel": "取消" + }, "addBoostTooltip": "智能体模式、图片或 Skill", "permissionMode": { "menuLabel": "权限模式", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index ec192fcb6e..3f1fc447b0 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -157,6 +157,19 @@ }, "sessions": { "newSession": "新增會話", + "dispatchRunningOn": "執行於 {{target}} · {{state}}", + "dispatchUnreachable": "目標無法連線", + "dispatchUnreachableDetails": "目標無法連線:{{target}} · {{error}}", + "dispatchTransportErrorFallback": "傳輸請求失敗", + "dispatchStates": { + "submitting": "待提交", + "submission_unknown": "正在確認提交狀態", + "queued": "排隊中", + "running": "$t(shared:statuses.running)", + "succeeded": "$t(shared:statuses.done)", + "failed": "$t(shared:statuses.failed)", + "cancelled": "$t(shared:statuses.cancelled)" + }, "newCodeSession": "新增 Code 會話", "newCoworkSession": "新增 Cowork 會話", "newExternalAgentSessionShort": "{{agentName}} 會話", @@ -1422,6 +1435,49 @@ "daysAgo": "{{count}} 日前" } }, + "dispatch": { + "configureTitle": "準備 {{target}}", + "workspacePath": "目標工作區", + "workspacePlaceholder": "/專案/路徑", + "check": "檢查", + "cliStatus": "BitFun CLI", + "cliReady": "就緒({{version}})", + "cliMissing": "未安裝或無法連線", + "cliIncompatible": "需要更新:{{details}}", + "protocolVersionMismatch": "協定版本 {{actual}},需要 {{expected}}", + "workspaceStatus": "$t(shared:features.workspace)", + "workspaceGit": "{{branch}} · {{dirty}}", + "workspaceDirectory": "目錄可用(不是 Git 儲存庫)", + "workspaceMissing": "路徑不存在或不是目錄", + "unknownBranch": "未知分支", + "dirty": "有未提交變更", + "clean": "乾淨", + "upstreamStatus": "上游", + "upstreamCounts": "領先 {{ahead}} · 落後 {{behind}}", + "modelStatus": "目標模型", + "modelReady": "就緒({{model}})", + "modelAutomatic": "目標預設模型", + "modelMissing": "目標上沒有可用的模型設定", + "installRequired": "安裝或更新 BitFun CLI", + "installDescription": "BitFun 會將已驗證的發行版安裝到目標使用者的主目錄。", + "version": "版本", + "downloadUrl": "下載位址", + "installConfirmTitle": "在此目標上安裝 BitFun CLI?", + "installConfirmMessage": "將從 {{url}} 下載版本 {{version}},安裝前驗證 SHA256 {{sha256}}。", + "installConfirm": "安裝", + "installing": "正在安裝…", + "installFailed": "CLI 安裝失敗。請檢查輸出後重試。", + "installOutput": "CLI 安裝輸出", + "installWaiting": "正在等待安裝輸出…", + "approvalTitle": "無人值守權限策略", + "approvalHint": "必須明確選擇,只對這個派發任務生效。", + "approvalReject": "拒絕並回報", + "approvalRejectDescription": "拒絕需要確認的操作,並在工作階段記錄中回報。", + "approvalAuto": "自動核准", + "approvalAutoDescription": "自動核准目標上的權限要求。傳送前仍會再次確認。", + "useTarget": "使用此目標", + "cancel": "取消" + }, "collapse": "收合", "expand": "展開", "retry": "重試" diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index c4da9e8ea3..5ce855fba6 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -626,6 +626,26 @@ "processingFailed": "處理失敗" }, "chatInput": { + "dispatch": { + "local": "本機", + "locked": "執行於 {{target}}(此任務不可變更)", + "current": "將此任務執行於 {{target}}", + "menuLabel": "這個任務在哪裡執行", + "sessionScope": "新任務", + "localSection": "本機", + "localDescription": "在目前 BitFun 應用程式中執行", + "sshSection": "SSH", + "loading": "正在載入目標…", + "noSshTargets": "沒有已儲存的 SSH 目標", + "sshDescription": "透過已儲存的 SSH 連線執行", + "addSsh": "新增 SSH 連線…", + "createFailed": "無法建立派發任務。", + "remoteTarget": "遠端目標", + "autoConfirmTitle": "允許無人值守地修改嗎?", + "autoConfirmMessage": "此任務將在 {{target}} 上執行,並自動核准權限要求。是否繼續?", + "autoConfirmAction": "自動核准並派發", + "autoConfirmCancel": "取消" + }, "addBoostTooltip": "智能體模式、圖片或 Skill", "permissionMode": { "menuLabel": "權限模式",