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
41 changes: 36 additions & 5 deletions docs/architecture/detached-task-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,26 @@ path and capture mode. Before packaging a later job, it recomputes a lightweight
fingerprint from the selected paths and their filesystem identity, size,
executable state, and write/change timestamps. An unchanged fingerprint
hard-links the cached immutable archive into the new job instead of rereading
and recompressing every file. Source mode ignores changes below ignored paths;
exact mode observes them. A selected entry change invalidates and atomically
replaces the cache. The per-job link remains immutable during submission, so a
later cache replacement cannot change an in-flight job's bytes.
and recompressing every file.

That fingerprint is metadata-only, so operations that leave every byte intact
still change it: `chmod`, an editor's write-then-rename, a `git checkout` round
trip. Because the target's own cache is keyed by the archive digest, a
controller miss forces a full retransfer as well, so a changed fingerprint alone
is not allowed to condemn the cache. Packaging therefore publishes the archive's
per-file manifest as a sidecar next to the cached archive, and a fingerprint
mismatch falls through to comparing the source against it: first structurally,
by path, kind, size, and executable bit, which needs no more I/O than the
fingerprint itself and rejects nearly every real change; then, only when the
structure is identical, by per-file SHA-256. An identical tree reuses the cached
archive and writes the new fingerprint back, so the content comparison is paid
once rather than on every later job. A cache entry with no manifest sidecar —
one written by an older build — silently repackages as before.

Source mode ignores changes below ignored paths; exact mode observes them. A
selected entry change invalidates and atomically replaces the cache. The per-job
link remains immutable during submission, so a later cache replacement cannot
change an in-flight job's bytes.

SSH transports the archive with SFTP after `workspace-begin`. Account-device
RPC uses bounded base64 chunks inside the existing end-to-end encrypted
Expand Down Expand Up @@ -209,7 +225,22 @@ per observer and the target has no controller lease. Explicitly listing jobs
for a selected target adopts observer-only routing records on that controller;
it does not copy sessions or acquire workspace/runtime ownership.

Target and outbound records are retained for 30 days after terminal state.
A cursor records how far into the event log an observer has read, not what it
drew, so a cursor on its own cannot restore a projection. The controller
therefore caches each observer's rendered transcript beside its outbound index,
one file per job, holding the projected turns together with the cursor that
produced them and the completeness facts that applied at that point. Storing
them in one document is what keeps them consistent: a restart resumes from the
cached cursor rather than any other stored one, because only that pair was
written together, and a truncated history stays marked as truncated. The cache
is versioned by the projection rules that wrote it, and anything missing,
corrupt, mismatched, or above the size ceiling replays the job from byte zero.
The controller stores the projection verbatim and never interprets it; caching
it creates no durable session and acquires no runtime ownership.

Target and outbound records are retained for 30 days after terminal state, as
are the cached transcripts, which are also dropped as soon as a projection is
deleted or archived.
Garbage collection never removes queued or running jobs. Removing a terminal
snapshot also removes only the managed directory bound to that job; an
arbitrary user-supplied target directory is never a cleanup target.
Expand Down
4 changes: 4 additions & 0 deletions src/apps/cli/src/peer_host/deny.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[
"dispatch_list_jobs",
"dispatch_answer",
"dispatch_append",
"dispatch_load_transcript",
"dispatch_save_transcript",
];

/// Desktop IDE surfaces that CLI Peer Host does not implement.
Expand Down Expand Up @@ -141,6 +143,8 @@ mod tests {
"dispatch_list_jobs",
"dispatch_answer",
"dispatch_append",
"dispatch_load_transcript",
"dispatch_save_transcript",
] {
assert!(is_local_only_command(command), "{command}");
}
Expand Down
47 changes: 45 additions & 2 deletions src/apps/desktop/src/api/dispatch_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ use bitfun_core::service::dispatch::{
DispatchAppendRequest, DispatchApplyResultRequest, DispatchConnectionRequest,
DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest,
DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest,
DispatchStatusRequest, DispatchSubmitRequest, DispatchTarget, DispatchTargetOption,
DispatchTargetRequest, OutboundDispatchStore, WorkspaceResultApplyOutcome,
DispatchSaveTranscriptRequest, DispatchStatusRequest, DispatchSubmitRequest, DispatchTarget,
DispatchTargetOption, DispatchTargetRequest, DispatchTranscriptRequest, OutboundDispatchStore,
WorkspaceResultApplyOutcome,
};
use bitfun_core::service::remote_ssh::dispatch_ssh::{
DispatchInstallPoll, DispatchInstallStart, DispatchSshProbe,
Expand Down Expand Up @@ -525,6 +526,48 @@ pub async fn dispatch_append(
.map_err(|error| error.to_string())
}

/// Read this controller's cached observer transcript for one dispatch job.
///
/// Purely local: it touches neither the target nor any local session runtime.
/// A missing or unreadable cache returns `null` and the observer replays the
/// job from the beginning.
#[tauri::command]
pub async fn dispatch_load_transcript(
path_manager: State<'_, Arc<PathManager>>,
request: DispatchTranscriptRequest,
) -> Result<Option<Value>, String> {
OutboundDispatchStore::new(path_manager.as_ref())
.read_transcript(&request.job_id)
.await
.map_err(|error| error.to_string())
}

/// Persist this controller's observer transcript for one dispatch job.
///
/// A `null` transcript erases the cache instead, which is how deleting a
/// projection drops its cached content right away.
///
/// Returns `false` when the transcript exceeds the cache ceiling, in which case
/// the previous entry is kept and the renderer keeps polling as before.
#[tauri::command]
pub async fn dispatch_save_transcript(
path_manager: State<'_, Arc<PathManager>>,
request: DispatchSaveTranscriptRequest,
) -> Result<bool, String> {
let store = OutboundDispatchStore::new(path_manager.as_ref());
let Some(transcript) = request.transcript else {
return store
.remove_transcript(&request.job_id)
.await
.map(|()| true)
.map_err(|error| error.to_string());
};
store
.write_transcript(&request.job_id, &transcript)
.await
.map_err(|error| error.to_string())
}

#[cfg(test)]
mod tests {
use super::decode_device_dispatch_rpc;
Expand Down
2 changes: 2 additions & 0 deletions src/apps/desktop/src/api/peer_host_invoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[
"dispatch_list_jobs",
"dispatch_answer",
"dispatch_append",
"dispatch_load_transcript",
"dispatch_save_transcript",
// One-click relay deploy SSHes from the controller to a user host
"relay_deploy_preflight",
"relay_deploy_install_docker",
Expand Down
8 changes: 8 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
),
("dispatch_answer", RemoteWorkspacePolicy::WorkspaceAgnostic),
("dispatch_append", RemoteWorkspacePolicy::WorkspaceAgnostic),
(
"dispatch_load_transcript",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"dispatch_save_transcript",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
("dispatch_status", RemoteWorkspacePolicy::WorkspaceAgnostic),
("dispatch_submit", RemoteWorkspacePolicy::WorkspaceAgnostic),
(
Expand Down
2 changes: 2 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1758,6 +1758,8 @@ pub async fn run() {
api::dispatch_api::dispatch_list_jobs,
api::dispatch_api::dispatch_answer,
api::dispatch_api::dispatch_append,
api::dispatch_api::dispatch_load_transcript,
api::dispatch_api::dispatch_save_transcript,
// Relay self-deploy API
api::relay_deploy_api::relay_deploy_preflight,
api::relay_deploy_api::relay_deploy_install_docker,
Expand Down
Loading