Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/apps/cli/src/dispatch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use protocol::{
DispatchListRequest, DispatchProbeRequest, DispatchProbeResponse, DispatchStatusRequest,
DispatchStatusResponse, DispatchSubmitRequest, DispatchSubmitResponse,
DispatchWorkspaceBeginRequest, DispatchWorkspaceChunkRequest, DispatchWorkspaceCommitRequest,
DispatchWorkspaceResultRequest,
DispatchWorkspaceProbe, DISPATCH_PROTOCOL_VERSION, MAX_DISPATCH_TEXT_BYTES,
};
use store::{CreateJobOutcome, DispatchStateRecord, DispatchStore};
Expand Down Expand Up @@ -71,6 +72,10 @@ pub(crate) async fn run_dispatch_verb(
DispatchWorkspaceCommitRequest,
>(input)?)?)
.context("encode workspace commit response"),
"workspace-result" => serde_json::to_value(workspace::result(parse::<
DispatchWorkspaceResultRequest,
>(input)?)?)
.context("encode workspace result response"),
_ => bail!("unsupported dispatch verb: {verb}"),
}
}
Expand Down Expand Up @@ -102,6 +107,9 @@ async fn probe(request: DispatchProbeRequest) -> Result<DispatchProbeResponse> {
"event_log_completeness".to_string(),
"workspace_snapshot_exact".to_string(),
"workspace_snapshot_chunked".to_string(),
// Optional on purpose: controllers must feature-detect this rather than
// require it, so an older target stays usable for everything else.
"workspace_result_bundle".to_string(),
];
if runner::is_supported() {
capabilities.push("detached_worker".to_string());
Expand Down
22 changes: 21 additions & 1 deletion src/apps/cli/src/dispatch/protocol.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};

use bitfun_agent_runtime::sdk::{PermissionReply, PermissionRequest};
use bitfun_services_core::dispatch_workspace::WorkspaceSnapshotMetadata;
use bitfun_services_core::dispatch_workspace::{WorkspaceResultSummary, WorkspaceSnapshotMetadata};

pub(crate) const DISPATCH_PROTOCOL_VERSION: u32 = 2;
pub(crate) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024;
Expand Down Expand Up @@ -186,6 +186,26 @@ pub(crate) struct DispatchWorkspaceCommitResponse {
pub(crate) metadata: WorkspaceSnapshotMetadata,
}

/// Ask the target to diff its terminal tree against the delivered snapshot.
///
/// Read-only on the target: it builds a bundle and reports what changed. The
/// controller decides whether to fetch it, and applying it locally is a
/// separate step the user confirms.
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct DispatchWorkspaceResultRequest {
pub(crate) job_id: String,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DispatchWorkspaceResultResponse {
/// Absolute path of the bundle on the target, for the controller to fetch.
pub(crate) bundle_path: String,
pub(crate) workspace_path: String,
pub(crate) summary: WorkspaceResultSummary,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct DispatchCancelRequest {
Expand Down
61 changes: 56 additions & 5 deletions src/apps/cli/src/dispatch/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use base64::Engine as _;
use bitfun_services_core::dispatch_workspace::{
extract_workspace_snapshot, WorkspaceSnapshotMetadata, MAX_SNAPSHOT_ARCHIVE_BYTES,
MAX_SNAPSHOT_DIRECTORIES, MAX_SNAPSHOT_FILES, MAX_SNAPSHOT_UNCOMPRESSED_BYTES,
WORKSPACE_SNAPSHOT_FORMAT_VERSION,
create_workspace_result_bundle, extract_workspace_snapshot, WorkspaceSnapshotManifest,
WorkspaceSnapshotMetadata, MAX_SNAPSHOT_ARCHIVE_BYTES, MAX_SNAPSHOT_DIRECTORIES,
MAX_SNAPSHOT_FILES, MAX_SNAPSHOT_UNCOMPRESSED_BYTES, WORKSPACE_SNAPSHOT_FORMAT_VERSION,
};
use serde::{Deserialize, Serialize};

use super::protocol::{
DispatchWorkspaceBeginRequest, DispatchWorkspaceBeginResponse, DispatchWorkspaceChunkRequest,
DispatchWorkspaceChunkResponse, DispatchWorkspaceCommitRequest,
DispatchWorkspaceCommitResponse, DISPATCH_PROTOCOL_VERSION,
DispatchWorkspaceCommitResponse, DispatchWorkspaceResultRequest,
DispatchWorkspaceResultResponse, DISPATCH_PROTOCOL_VERSION,
};
use super::store::{
atomic_write_json, create_private_dir, read_json, remove_file_if_present,
Expand All @@ -24,6 +25,10 @@ use super::store::{
const UPLOAD_RECORD_FILE: &str = "upload.json";
const UPLOAD_ARCHIVE_FILE: &str = "workspace.tar.gz";
const CURRENT_WORKSPACE_DIR: &str = "current";
/// The delivered snapshot's manifest, kept as the baseline a result diff is
/// computed against.
const BASELINE_MANIFEST_FILE: &str = "baseline-manifest.json";
const RESULT_BUNDLE_FILE: &str = "result.tar.gz";
const MAX_CHUNK_BYTES: usize = 256 * 1024;
const MAX_CHUNK_BASE64_BYTES: usize = 384 * 1024;
const MAX_MATERIALIZATION_ERROR_BYTES: usize = 16 * 1024;
Expand Down Expand Up @@ -297,6 +302,45 @@ pub(crate) fn commit(
Ok(pending_commit_response(&record))
}

/// Diff the terminal workspace against the snapshot it was given and package
/// what changed.
///
/// Only valid for snapshot-delivered jobs: a job that ran against a directory
/// the user already had has no baseline to diff against, and BitFun never took
/// ownership of that directory.
pub(crate) fn result(
request: DispatchWorkspaceResultRequest,
) -> Result<DispatchWorkspaceResultResponse> {
let store = DispatchStore::open_default()?;
let upload_dir = store.workspace_upload_dir(&request.job_id)?;
let lock_path = workspace_upload_lock_path(&store, &request.job_id);
let _lock = JobLock::exclusive(&lock_path)?;

let record: WorkspaceUploadRecord = read_json(&upload_dir.join(UPLOAD_RECORD_FILE))
.context("this job did not receive a workspace snapshot")?;
ensure_upload_identity(&record, &request.job_id)?;
if record.state != WorkspaceUploadState::Committed {
bail!("workspace snapshot is not committed yet");
}
let baseline: WorkspaceSnapshotManifest =
read_json(&upload_dir.join(BASELINE_MANIFEST_FILE)).context(
"this job predates result bundles; its baseline manifest was not recorded",
)?;

let current = upload_dir.join(CURRENT_WORKSPACE_DIR);
if !is_real_directory(&current) {
bail!("managed dispatch workspace is missing");
}
let bundle_path = upload_dir.join(RESULT_BUNDLE_FILE);
let summary = create_workspace_result_bundle(&current, &baseline, &bundle_path)?;
set_private_file_permissions(&bundle_path)?;
Ok(DispatchWorkspaceResultResponse {
bundle_path: bundle_path.to_string_lossy().to_string(),
workspace_path: current.to_string_lossy().to_string(),
summary,
})
}

/// Detached target-side materialization. The short `workspace-commit` RPC
/// starts this process and subsequent commit calls poll the durable record, so
/// extraction is not bounded by an SSH or Relay request timeout.
Expand Down Expand Up @@ -329,7 +373,14 @@ fn materialize_in_store(store: &DispatchStore, job_id: &str) -> Result<()> {
let archive_path = upload_dir.join(UPLOAD_ARCHIVE_FILE);
validate_complete_archive(&archive_path, &record.metadata)?;
let staging = upload_dir.join(format!(".staging-{}", uuid::Uuid::new_v4().as_simple()));
extract_workspace_snapshot(&archive_path, &staging, &record.metadata)?;
let manifest = extract_workspace_snapshot(&archive_path, &staging, &record.metadata)?;
// Persist S0 as the baseline for a later result diff. The controller
// deletes its own copy of the archive as soon as the job is durably
// owned here, so this is the only surviving record of what was sent —
// and its per-file digests are what make a diff possible for workspaces
// that are not git repositories.
atomic_write_json(&upload_dir.join(BASELINE_MANIFEST_FILE), &manifest)
.context("persist dispatch workspace baseline manifest")?;
fs::rename(&staging, &current).with_context(|| {
format!(
"publish dispatch workspace {} -> {}",
Expand Down
2 changes: 2 additions & 0 deletions src/apps/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,8 @@ pub(crate) enum DispatchAction {
WorkspaceChunk,
#[command(name = "__workspace_commit", hide = true)]
WorkspaceCommit,
#[command(name = "__workspace_result", hide = true)]
WorkspaceResult,
#[command(name = "__workspace_materialize", hide = true)]
WorkspaceMaterialize {
#[arg(long)]
Expand Down
1 change: 1 addition & 0 deletions src/apps/cli/src/root_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub(crate) async fn handle_dispatch_action(action: DispatchAction) -> Result<()>
DispatchAction::WorkspaceBegin => "workspace-begin",
DispatchAction::WorkspaceChunk => "workspace-chunk",
DispatchAction::WorkspaceCommit => "workspace-commit",
DispatchAction::WorkspaceResult => "workspace-result",
};
let result = async {
use std::io::{IsTerminal, Read};
Expand Down
55 changes: 54 additions & 1 deletion src/apps/desktop/src/api/dispatch_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ use async_trait::async_trait;
use bitfun_core::infrastructure::PathManager;
use bitfun_core::service::dispatch::{
answer_device_dispatch, answer_dispatch, append_device_dispatch, append_dispatch,
apply_dispatch_result, DispatchApplyResultRequest, WorkspaceResultApplyOutcome,
cancel_device_dispatch, cancel_dispatch, cancel_dispatch_cli_install,
get_device_dispatch_status, get_dispatch_status, list_device_dispatch_jobs, list_dispatch_jobs,
list_dispatch_targets, poll_dispatch_cli_install, probe_device_dispatch_target,
probe_dispatch_target, start_dispatch_cli_install, submit_device_dispatch, submit_dispatch,
probe_dispatch_target, pull_dispatch_result, start_dispatch_cli_install,
start_dispatch_cli_source_build, submit_device_dispatch, submit_dispatch,
sync_dispatch_model_config,
DeviceDispatchRpc, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest,
DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest,
Expand Down Expand Up @@ -145,6 +147,22 @@ pub async fn dispatch_install_cli_start(
.map_err(|error| error.to_string())
}

/// Build the CLI from source on the target. Offered when no published binary
/// can run there.
#[tauri::command]
pub async fn dispatch_install_cli_source_start(
state: State<'_, AppState>,
request: DispatchConnectionRequest,
) -> Result<DispatchInstallStart, String> {
let manager = state
.get_ssh_manager_async()
.await
.map_err(|error| error.to_string())?;
start_dispatch_cli_source_build(&manager, request)
.await
.map_err(|error| error.to_string())
}

#[tauri::command]
pub async fn dispatch_install_cli_poll(
state: State<'_, AppState>,
Expand Down Expand Up @@ -248,6 +266,41 @@ pub async fn dispatch_status(
.map_err(|error| error.to_string())
}

/// Download what a finished snapshot job changed on its target.
///
/// Fetch and report only — the caller shows the diff and the user decides
/// whether any of it reaches their workspace.
#[tauri::command]
pub async fn dispatch_pull_result(
state: State<'_, AppState>,
path_manager: State<'_, Arc<PathManager>>,
request: DispatchJobRequest,
) -> Result<Value, String> {
let store = OutboundDispatchStore::new(path_manager.as_ref());
let manager = state
.get_ssh_manager_async()
.await
.map_err(|error| error.to_string())?;
pull_dispatch_result(&manager, &store, request)
.await
.map_err(|error| error.to_string())
}

/// Apply a pulled result bundle to a local workspace.
///
/// Aborts without writing when a path changed on both sides, unless the user
/// explicitly chose to take the target's version.
#[tauri::command]
pub async fn dispatch_apply_result(
path_manager: State<'_, Arc<PathManager>>,
request: DispatchApplyResultRequest,
) -> Result<WorkspaceResultApplyOutcome, String> {
let store = OutboundDispatchStore::new(path_manager.as_ref());
apply_dispatch_result(&store, request)
.await
.map_err(|error| error.to_string())
}

#[tauri::command]
pub async fn dispatch_cancel(
state: State<'_, AppState>,
Expand Down
3 changes: 3 additions & 0 deletions src/apps/desktop/src/api/peer_host_invoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,15 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[
"dispatch_list_targets",
"dispatch_probe_target",
"dispatch_install_cli_start",
"dispatch_install_cli_source_start",
"dispatch_install_cli_poll",
"dispatch_install_cli_cancel",
"dispatch_sync_model_config",
"dispatch_submit",
"dispatch_status",
"dispatch_cancel",
"dispatch_pull_result",
"dispatch_apply_result",
"dispatch_list_jobs",
"dispatch_answer",
"dispatch_append",
Expand Down
12 changes: 12 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
"dispatch_install_cli_poll",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"dispatch_install_cli_source_start",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"dispatch_install_cli_start",
RemoteWorkspacePolicy::WorkspaceAgnostic,
Expand All @@ -357,6 +361,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
"dispatch_list_jobs",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"dispatch_apply_result",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"dispatch_pull_result",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"dispatch_list_targets",
RemoteWorkspacePolicy::WorkspaceAgnostic,
Expand Down
3 changes: 3 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1746,12 +1746,15 @@ pub async fn run() {
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_source_start,
api::dispatch_api::dispatch_install_cli_poll,
api::dispatch_api::dispatch_install_cli_cancel,
api::dispatch_api::dispatch_sync_model_config,
api::dispatch_api::dispatch_submit,
api::dispatch_api::dispatch_status,
api::dispatch_api::dispatch_cancel,
api::dispatch_api::dispatch_pull_result,
api::dispatch_api::dispatch_apply_result,
api::dispatch_api::dispatch_list_jobs,
api::dispatch_api::dispatch_answer,
api::dispatch_api::dispatch_append,
Expand Down
Loading