From c8e9659a0634f72eea417e46503ac4d0243a4fdc Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 1 Aug 2026 01:05:54 -0700 Subject: [PATCH] refactor(dispatch): deliver tasks through Git worktrees --- Cargo.lock | 2 - docs/architecture/detached-task-dispatch.md | 335 +- src/apps/cli/src/dispatch/mod.rs | 99 +- src/apps/cli/src/dispatch/protocol.rs | 160 +- src/apps/cli/src/dispatch/runner.rs | 82 +- src/apps/cli/src/dispatch/store.rs | 373 ++- src/apps/cli/src/dispatch/workspace.rs | 2746 ++++++++++++----- src/apps/cli/src/main.rs | 54 +- src/apps/cli/src/peer_host/deny.rs | 4 + src/apps/cli/src/peer_host/dispatch.rs | 11 +- src/apps/cli/src/root_handlers.rs | 21 +- src/apps/desktop/src/api/agentic_api.rs | 3 + src/apps/desktop/src/api/dispatch_api.rs | 51 +- src/apps/desktop/src/api/dispatch_host.rs | 11 +- src/apps/desktop/src/api/peer_host_invoke.rs | 3 +- .../src/api/remote_workspace_policy.rs | 6 +- src/apps/desktop/src/lib.rs | 3 +- src/apps/server/src/routes/dispatch.rs | 34 +- .../tools/implementations/worktree_tool.rs | 2 + .../core/src/service/dispatch/baseline.rs | 753 +++++ .../core/src/service/dispatch/controller.rs | 764 +++-- .../src/service/dispatch/device_controller.rs | 717 +++-- .../assembly/core/src/service/dispatch/mod.rs | 1141 ++----- .../core/src/service/dispatch/preparation.rs | 959 ++++++ .../core/src/service/dispatch/target.rs | 92 +- .../assembly/core/src/service/worktree/mod.rs | 368 ++- .../src/service/worktree/session_binding.rs | 2 + .../events/src/frontend_projection.rs | 21 + src/crates/services/services-core/Cargo.toml | 4 +- .../services-core/src/dispatch_workspace.rs | 2059 +----------- .../src/remote_ssh/dispatch_ssh.rs | 791 +++-- .../dispatch/DispatchInstallDialog.scss | 32 +- .../dispatch/DispatchInstallDialog.test.tsx | 255 +- .../dispatch/DispatchInstallDialog.tsx | 440 +-- .../dispatch/DispatchJobObserver.test.ts | 384 ++- .../features/dispatch/DispatchJobObserver.ts | 436 ++- .../dispatch/DispatchResultDialog.scss | 18 +- .../dispatch/DispatchResultDialog.test.tsx | 166 +- .../dispatch/DispatchResultDialog.tsx | 269 +- .../dispatch/DispatchTargetPicker.tsx | 2 +- src/web-ui/src/features/dispatch/README.md | 114 +- .../dispatch/dispatch.contract.test.ts | 38 +- .../src/features/dispatch/dispatchApi.ts | 37 +- .../dispatch/dispatchJobStore.test.ts | 82 +- .../src/features/dispatch/dispatchJobStore.ts | 46 +- .../dispatch/dispatchPreflight.test.ts | 27 +- .../features/dispatch/dispatchPreflight.ts | 31 +- src/web-ui/src/features/dispatch/types.ts | 95 +- .../features/dispatch/useDispatchTargets.ts | 7 +- .../ssh-remote/SSHConnectionDialog.test.tsx | 50 +- .../ssh-remote/SSHConnectionDialog.tsx | 5 +- .../src/flow_chat/components/ChatInput.tsx | 48 +- .../components/ChatInputWorkspaceStrip.scss | 4 +- .../ChatInputWorkspaceStrip.test.tsx | 83 + .../components/ChatInputWorkspaceStrip.tsx | 77 +- .../ChatInputWorkspaceStripLayout.test.ts | 16 +- .../modern/usePermissionRequests.test.tsx | 1 - .../EventHandlerModule.test.ts | 106 + .../flow-chat-manager/EventHandlerModule.ts | 9 +- .../flow-chat-manager/MessageModule.test.ts | 11 +- .../flow-chat-manager/MessageModule.ts | 25 +- .../flow-chat-manager/SessionModule.ts | 5 +- .../src/flow_chat/store/FlowChatStore.test.ts | 151 + .../src/flow_chat/store/FlowChatStore.ts | 106 +- src/web-ui/src/flow_chat/types/flow-chat.ts | 6 +- .../api/adapters/peer-device-adapter.ts | 2 + src/web-ui/src/locales/en-US/common.json | 79 +- src/web-ui/src/locales/en-US/flow-chat.json | 9 +- src/web-ui/src/locales/en-US/worktrees.json | 1 + src/web-ui/src/locales/zh-CN/common.json | 79 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 9 +- src/web-ui/src/locales/zh-CN/worktrees.json | 1 + src/web-ui/src/locales/zh-TW/common.json | 79 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 9 +- src/web-ui/src/locales/zh-TW/worktrees.json | 1 + 75 files changed, 9277 insertions(+), 5845 deletions(-) create mode 100644 src/crates/assembly/core/src/service/dispatch/baseline.rs create mode 100644 src/crates/assembly/core/src/service/dispatch/preparation.rs diff --git a/Cargo.lock b/Cargo.lock index 9156eab173..8372c29820 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1424,7 +1424,6 @@ dependencies = [ "chrono", "dunce", "filetime", - "flate2", "fs2", "git2", "globset", @@ -1439,7 +1438,6 @@ dependencies = [ "serde_yaml", "sha2", "similar", - "tar", "tempfile", "thiserror 2.0.19", "tokio", diff --git a/docs/architecture/detached-task-dispatch.md b/docs/architecture/detached-task-dispatch.md index 4574166414..96239bd0ba 100644 --- a/docs/architecture/detached-task-dispatch.md +++ b/docs/architecture/detached-task-dispatch.md @@ -15,8 +15,8 @@ dispatch data plane. There are three roles: -- The **controller** selects a target, prepares an optional workspace snapshot, - submits a job, and observes it by cursor. +- The **controller** selects a target, creates and claims a managed baseline + worktree, submits a job, and observes it by cursor. - The **target host** owns the job, worker process, local session, workspace lease, event log, permission mailbox, and terminal state. - A **transport adapter** moves the same narrow JSON protocol over SSH or an @@ -28,145 +28,154 @@ stores only an outbound observer record under controller's normal session store. A target session is an ordinary local session on the target and can be resumed there. -The Relay is an opaque router. Device requests, workspace chunks, and responses -are encrypted with the account master key before the Relay receives them. Relay -storage is not used for workspace contents. +The Relay is an opaque router. Device requests, Git bundle chunks, and responses +are encrypted with the account master key before the Relay receives them. +Relay storage is not used for repository contents. ## Workspace delivery -`workspacePath` in a submit request identifies a directory on the target. It -does not imply that similarly named directories on two machines are related. -Dispatch therefore supports three explicit delivery modes. - -### Existing target directory - -`existing` uses a directory that already exists on the target. Probe returns its -canonical path and Git facts before submit. BitFun never clones, fetches, -checks out, stashes, or rewrites that directory as part of dispatch. - -### One-shot source snapshot - -`snapshot-source` captures the controller workspace while honoring repository -ignore rules. It includes tracked and non-ignored source files, including -hidden source such as `.github/`, while excluding ignored dependency caches, -build output, and local secrets. It uses the same verified, one-shot upload, -materialization, result, and conflict rules as an exact snapshot. The filtered -input set is carried in the existing exact-snapshot wire envelope, so compatible -targets do not need a second materialization protocol. - -This is the default snapshot choice for ordinary source workspaces. Users who -need ignored runtime inputs must choose the exact mode explicitly and confirm -its wider data boundary. - -### One-shot exact snapshot - -`snapshot-exact` captures the controller workspace at submit time and materializes it -below: +Dispatch workspace delivery is Git-only. The composer exposes the dispatch +picker only for a Git repository or managed worktree, and non-UI callers are +validated again by the controller. A caller cannot nominate an unrelated +directory on the target as the execution root. Target discovery exposes an SSH +connection's identity and display metadata, but does not turn its saved +`defaultWorkspace` into a Dispatch execution directory; every detached job gets +the managed target worktree described below. + +### Controller baseline + +After the target protocol is known to be compatible, the controller uses +`WorktreeService::create` to create a normal managed worktree at the selected +base revision (`HEAD` by default). The revision is resolved once to an +immutable commit before delivery. The worktree is job-scoped and receives the branch +`/dispatch/`, where `branchPrefix` comes from the shared +worktree settings. + +The outbound record persists the baseline worktree id and path, base commit, +branch, source repository identity, optional remote URL, and last synchronized +head. The worktree is claimed by `dispatch:`, which exempts it from +automatic worktree retention while the outbound job still depends on it. Claim +cleanup is ordered: first release `dispatch:`, then delete the outbound +record. If release fails, the record is retained so cleanup can be retried and +the claim cannot be orphaned. The worktree remains a normal managed worktree: +users can inspect it with ordinary Git tools, and the normal worktree retention +policy resumes after release. + +If `includeUncommitted` is selected, the normal worktree copy-local-changes +path is used and Git-visible changes are committed inside the baseline +worktree. The user's checkout is never staged, committed, switched, reset, or +otherwise changed. Git-ignored runtime inputs such as local `.env` files and +build output are not delivery inputs. + +### Controller preparation journal + +Setup that can outlive one request is recorded before the outbound submit is +acknowledged. The controller keeps an owner-only crash journal at +`~/.bitfun/dispatch/outbound/.preparations/.json` and retains it through +the target's validated submit acknowledgement. Preparation, retry, and recovery +for the same job are serialized by one per-job run lock, so an expired-entry +recovery cannot race a live attempt. + +For automatic SSH CLI setup, audit progress is a fallible durable write rather +than an in-memory callback. The `cli-install-started` transition is persisted +before the first remote installer mutation, subsequent success or failure is +persisted before setup returns, and any audit write failure stops submission. +Keeping the journal until acknowledgement lets a retry recover setup actions +that completed remotely even if the controller exited before submit finished. + +Before creating the baseline claim, preparation records the stable project +workspace path that owns the worktree registry rather than relying on a linked +worktree path that may disappear. An expired preparation lease is necessary but +not sufficient for recovery to release `dispatch:`: no durable outbound +record may own the same baseline. A matching outbound owner conservatively +keeps the claim, and an outbound-owner read error is treated as ambiguous and +also keeps it. Only an expired, provably unowned preparation may release its +claim; cleanup failures retain recoverable state for a later retry. + +### Target repository and worktree + +The controller resolves the repository remote URL when one exists and derives +a stable `repoKey`. The target keeps an owner-only bare repository cache at: ```text -~/.bitfun/dispatch/workspaces//current/ +~/.bitfun/dispatch/repos/ ``` -The snapshot includes regular files and empty directories, including hidden and -ignored files, because the mode promises the workspace's current contents -rather than a Git checkout. The controller must show that this can include -`.env`, local credentials, build output, and other ignored data and require -explicit confirmation. - -The following entries are not silently copied: - -- every entry named `.git`, because worktree pointers, nested object stores, - hooks, and credentials are repository metadata rather than workspace input; -- symbolic links, to avoid following data outside the selected root or creating - target-dependent aliases; -- sockets, devices, FIFOs, and other special files; -- paths that cannot be represented as portable UTF-8 relative paths. - -Encountering any unsupported entry fails packaging and names the entry. A -successful manifest therefore describes every delivered entry; there is no -best-effort omission. - -The archive and manifest are bounded by explicit file-count, per-file, and total -byte limits: 100,000 files, 100,000 directories, 256 MiB per file, 2 GiB -uncompressed, and 1 GiB compressed. The controller computes a SHA-256 digest -and sends immutable upload metadata. The target writes to an owner-only staging -file, rejects offset mismatches, verifies size and digest, validates every -archive path and entry type, extracts into a new staging directory, and -atomically publishes `current`. Materialization runs as a detached target -process; `workspace-commit` starts or polls it, so a single SSH or Relay RPC -timeout cannot kill a large extraction. A repeated begin/chunk/commit for the -same job and digest is idempotent; a different digest for the same job is a -conflict. - -Packaging is one deterministic traversal, not an operating-system filesystem -snapshot. A file that changes size or modification time while it is read makes -the package fail, but coordinated edits across multiple files can still span -the traversal interval. Callers that require an application-consistent source -must quiesce the source or select a filesystem snapshot as the source path. - -The controller retains the latest verified archive for each canonical source -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. - -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 -`HostInvoke` envelope. Neither transport puts source bytes in command-line -arguments, process listings, logs, or the outbound observer record. - -After a target has fully verified and materialized a snapshot, it retains one -owner-only archive keyed by the archive SHA-256. A later job with identical -metadata attaches that immutable archive and reports the full retained offset, -so both SSH and account-device controllers skip the source transfer. The -temporary per-job archive link is removed after materialization; each job still -gets its own writable `current/` directory, so cache reuse never makes jobs -share writes. Cache entries expire after 30 days without a hit. +`workspace-provision` creates or refreshes that repository, fetches its remote +without interactive credential prompts, and checks for the requested base +commit. When the commit is reachable, the target creates the job worktree and +branch at: -## Synchronization semantics +```text +~/.bitfun/dispatch/worktrees/ +``` -A snapshot is an immutable input boundary, not a live shared folder: +If the target cannot reach the commit, it returns `needsBundle` and the commits +it already has. The controller then creates a Git bundle advertised by the +job's named branch, excluding known target tips where possible. Bundle upload +is bound to the job, size, and SHA-256; the target verifies the digest, runs +`git bundle verify`, imports the branch into the bare repository, and retries +provisioning. This covers unpushed commits and repositories with no usable +remote without requiring origin write access. -1. The controller captures version `S0`. -2. The target verifies and publishes `S0`. -3. The target becomes authoritative for all writes during the job. -4. Observers pull target events, permissions, and terminal state by cursor. +SSH carries large bundle bytes over the established SFTP channel. Account-device +delivery uses bounded base64 chunks inside the end-to-end encrypted +`HostInvoke` envelope. Both transports use the same provision, digest, +idempotency, branch, and target-path contract. Repository caches that are no +longer referenced are eligible for retention cleanup after 30 days. -The controller does not mirror local edits made after `S0`, and target writes -are not merged automatically into a possibly changed controller workspace. -Continuous bidirectional synchronization would require conflict detection, -delete semantics, editor coordination, and a controller that remains online, -which contradicts detached execution. +## Synchronization semantics -Returning code is a separate, explicit result operation. A future result bundle -may expose an artifact or patch derived from `S0` and the terminal target tree; -applying it must remain a user-confirmed local operation. Until that operation -exists, the UI states that snapshot results remain on the target and shows the -managed target path. +The controller baseline and target worktree start at the same immutable base +commit. They are not a live shared directory: edits made later in the user's +checkout are unrelated to the running job, and target writes remain on the +job branch until the user requests synchronization. + +The one-click synchronization operation is available while a job is running +and after it reaches a terminal state: + +1. The target stages Git-visible changes in its job worktree and creates a + commit when necessary. +2. The target validates that the worktree is still on the job's named branch. + For the first synchronization, `knownHead` is the immutable `baseCommit`; + afterward it is the last head successfully stored by the controller. The + target accepts `knownHead` only when it resolves to a commit and is an + ancestor of the current branch head. +3. The target creates an incremental Git bundle for + `..` and reports its branch, head commit, commit count, + changed-file list, size, and SHA-256. +4. The controller transfers and verifies the bundle, checks that its managed + baseline is still on the same job branch, fetches the reported branch into + the baseline repository, and advances the baseline worktree with + `--ff-only`. +5. The outbound record stores the synchronized head and the transfer artifact + is removed only after that advance succeeds. + +Each controller-side synchronization invocation carries a fresh `operationId`, +and every poll for that invocation reuses it. This keeps a completed no-op +(`headCommit == knownHead`) idempotent for its current poll loop while still +letting a later click at the same head start a new check for work produced by a +still-running agent. A changed result remains cached until its head is +acknowledged, so a failed bundle transfer or local fast-forward can retry safely. + +The user's checkout is never changed by synchronization. The baseline +worktree is the review boundary; the user can test there and then merge or +rebase the dispatch branch with ordinary Git tools. There is no path-overwrite +or conflict-resolution mode. If either worktree left the named job branch, or +if the baseline was deleted or gained divergent commits, synchronization fails +visibly instead of resetting or rewriting branch metadata. A checkpoint while +the job is running can collide with a transient Git or index lock held by the +worker; that failure is retryable, does not advance `knownHead`, and the same +synchronization request can be retried after the lock clears. ## Protocol -The target CLI owns the transport-independent protocol and durable store. +The target CLI owns transport-independent dispatch protocol version 3 and the +durable store. Version 3 is intentionally incompatible with targets that do +not implement Git worktree delivery. SSH submission can repair that mismatch +through signed release installation; an account device must be upgraded as a +BitFun device. + Public job verbs are: | Verb | Purpose | @@ -179,9 +188,26 @@ Public job verbs are: | `answer` | Resolve one persisted permission request for `remote` approval policy. | | `append` | Queue an idempotent steering message for the active turn. | -Workspace upload uses the internal `workspace-begin`, `workspace-chunk`, and -`workspace-commit` verbs. They are target data-plane operations and are not -normal product or Peer Device Mode commands. +Git delivery and synchronization use these internal data-plane verbs: + +| Verb | Purpose | +| --- | --- | +| `workspace-provision` | Ensure the shared bare repository contains `baseCommit` and create the job branch and worktree, or return `needsBundle`. | +| `workspace-bundle-begin` | Bind an owner-only incoming bundle to its job, size, and SHA-256 and report the retained offset. | +| `workspace-bundle-chunk` | Append one bounded account-device bundle chunk at the expected offset. | +| `workspace-bundle-commit` | Verify and import the completed base bundle into the target repository. | +| `workspace-sync` | Commit target changes when needed and create the branch bundle returned to the controller. | +| `workspace-sync-chunk` | Read one bounded account-device result chunk. | + +These verbs are not normal product commands. Account-device transport reserves +the corresponding `dispatch_target_workspace_*` names and routes them directly +to the target CLI before the attached Peer Host bridge. + +Every compatible target advertises `workspace_git_worktree`, +`workspace_git_bundle_upload`, and `workspace_git_sync`. These are required +capabilities, not optional feature detection. `workspace_serialization` +continues to guarantee that workers sharing one canonical execution path are +locked correctly. `dispatch_worker_cli_profile` is a required execution-safety capability. It means every dispatch process selects `DeliveryProfile::Cli` before model/config @@ -189,11 +215,18 @@ inspection can lazily initialize product-full tool state. Controllers must check it both during target setup and immediately before submission; package version equality is not evidence of this behavior. -CLI installation smoke-tests the same capability before replacing an existing -target binary. An untagged Desktop development build may, after the normal -explicit source-build confirmation, archive its clean current Git commit and -build that exact source on the target. This avoids reinstalling an older -same-semver release while keeping executable transfer an explicit user action. +`probe` is read-only and never installs software. Immediately before SSH +provisioning, submission probes again and automatically installs or upgrades a +compatible prebuilt `bitfun` release when needed. Release resolution stays +bound to the expected OS and architecture; the controller verifies the +checksum sidecar, its publisher signature when present, and the mandatory +archive signature, pins the SHA-256 passed to the installer, waits with a +bounded deadline, and probes the installed binary again before continuing. + +A source build is different: it uploads the controller's repository and +compiles it on the target. It therefore remains a separate user-confirmed +operation and uses only the clean, confirmed controller revision. Automatic +prebuilt installation never escalates to a source build. Account-device transport wraps target verbs in names reserved for detached dispatch, such as `dispatch_target_submit`. They are handled before the @@ -202,11 +235,12 @@ Conversely, controller-side commands such as `dispatch_submit` remain local-only in every Peer Device Mode deny table. Disconnecting the last Peer controller must not cancel or hide a detached dispatch job. -An account target must have a compatible `bitfun dispatch` runner. A CLI daemon -already satisfies this. The Desktop account host delegates to an installed -`bitfun` binary (including a package-manager symlink); if none is available, -probe reports the missing runner and submission remains disabled rather than -falling back to local execution. +An account target must already have a compatible `bitfun dispatch` runner. A +CLI daemon already satisfies this. The Desktop account host delegates to an +installed `bitfun` binary (including a package-manager symlink); if none is +available, probe reports the missing runner and submission remains disabled +rather than falling back to local execution. Device dispatch never performs +SSH-style installation through the Relay. ## Event and observer contract @@ -240,10 +274,11 @@ 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. +deleted or archived. Garbage collection never removes queued or running jobs. +Target cleanup is limited to the job's managed worktree and private transfer +state; shared repository caches have their own last-used retention check. On +the controller, expiring an outbound record releases its baseline worktree +claim before normal worktree retention can consider that worktree. ## Approval and supervision @@ -264,6 +299,10 @@ originally submitted the job. - A missing or offline target fails submit; the Relay does not queue jobs. - A target missing a required behavioral capability fails preflight before a durable job is created, even when its CLI package version matches. +- A signed prebuilt SSH release may be installed automatically. Missing + platform support, failed signature or digest verification, installer timeout, + or an incompatible post-install probe fails closed before workspace + provisioning. - A lost submit response leaves `submission_unknown`; status or an idempotent retry reconciles the target's durable truth. - A live PID that no longer matches the exact worker command is never signaled @@ -272,7 +311,9 @@ originally submitted the job. because replay could duplicate tool side effects. The native target session remains available for manual resume. - Prompt and event pages remain below the smallest host transport envelope. -- Workspace digest, archive traversal, unsupported entry, or size failures - happen before job submission and leave no executable target workspace. - Detached materialization failures are persisted and returned by later commit - polls instead of being hidden in a discarded child-process stderr stream. +- Invalid repository keys, commit ids, branch names, bundle paths, offsets, + sizes, digests, prerequisites, or Git verification results fail before job + submission. No failure may redirect a transfer outside the managed dispatch + directories or silently run against an unrelated target directory. +- A missing baseline worktree or rejected fast-forward leaves both Git histories + intact and returns a visible synchronization error. diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index cbce901d79..32805c73be 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -18,9 +18,10 @@ use protocol::{ DispatchCancelRequest, DispatchCancelResponse, DispatchJobListEntry, DispatchJobState, DispatchListRequest, DispatchProbeRequest, DispatchProbeResponse, DispatchStatusRequest, DispatchStatusResponse, DispatchSubmitRequest, DispatchSubmitResponse, - DispatchWorkspaceBeginRequest, DispatchWorkspaceChunkRequest, DispatchWorkspaceCommitRequest, - DispatchWorkspaceProbe, DispatchWorkspaceResultChunkRequest, DispatchWorkspaceResultRequest, - DISPATCH_PROTOCOL_VERSION, MAX_DISPATCH_TEXT_BYTES, + DispatchWorkspaceBundleBeginRequest, DispatchWorkspaceBundleChunkRequest, + DispatchWorkspaceBundleCommitRequest, DispatchWorkspaceProbe, + DispatchWorkspaceProvisionRequest, DispatchWorkspaceSyncChunkRequest, + DispatchWorkspaceSyncRequest, DISPATCH_PROTOCOL_VERSION, MAX_DISPATCH_TEXT_BYTES, }; use store::{CreateJobOutcome, DispatchStateRecord, DispatchStore}; @@ -60,28 +61,36 @@ pub(crate) async fn run_dispatch_verb( serde_json::to_value(answer(parse(input)?)?).context("encode permission answer") } "append" => serde_json::to_value(append(parse(input)?)?).context("encode appended message"), - "workspace-begin" => serde_json::to_value(workspace::begin(parse::< - DispatchWorkspaceBeginRequest, + "workspace-provision" => serde_json::to_value(workspace::provision(parse::< + DispatchWorkspaceProvisionRequest, >(input)?)?) - .context("encode workspace begin response"), - "workspace-chunk" => serde_json::to_value(workspace::chunk(parse::< - DispatchWorkspaceChunkRequest, - >(input)?)?) - .context("encode workspace chunk response"), - "workspace-commit" => serde_json::to_value(workspace::commit(parse::< - DispatchWorkspaceCommitRequest, - >(input)?)?) - .context("encode workspace commit response"), - "workspace-result" => serde_json::to_value(workspace::result(parse::< - DispatchWorkspaceResultRequest, - >(input)?)?) - .context("encode workspace result response"), - "workspace-result-chunk" => { - serde_json::to_value(workspace::result_chunk(parse::< - DispatchWorkspaceResultChunkRequest, + .context("encode workspace provision response"), + "workspace-bundle-begin" => { + serde_json::to_value(workspace::bundle_begin(parse::< + DispatchWorkspaceBundleBeginRequest, + >(input)?)?) + .context("encode workspace bundle begin response") + } + "workspace-bundle-chunk" => { + serde_json::to_value(workspace::bundle_chunk(parse::< + DispatchWorkspaceBundleChunkRequest, >(input)?)?) - .context("encode workspace result chunk response") + .context("encode workspace bundle chunk response") } + "workspace-bundle-commit" => { + serde_json::to_value(workspace::bundle_commit(parse::< + DispatchWorkspaceBundleCommitRequest, + >(input)?)?) + .context("encode workspace bundle commit response") + } + "workspace-sync" => serde_json::to_value(workspace::sync(parse::< + DispatchWorkspaceSyncRequest, + >(input)?)?) + .context("encode workspace sync response"), + "workspace-sync-chunk" => serde_json::to_value(workspace::sync_chunk(parse::< + DispatchWorkspaceSyncChunkRequest, + >(input)?)?) + .context("encode workspace sync chunk response"), _ => bail!("unsupported dispatch verb: {verb}"), } } @@ -90,8 +99,16 @@ pub(crate) async fn run_worker(job_id: String) -> Result<()> { worker::run(job_id).await } -pub(crate) fn run_workspace_materializer(job_id: String) -> Result<()> { - workspace::materialize(job_id) +pub(crate) fn run_workspace_provision(job_id: String) -> Result<()> { + workspace::run_provision(job_id) +} + +pub(crate) fn run_workspace_bundle_commit(job_id: String) -> Result<()> { + workspace::run_bundle_commit(job_id) +} + +pub(crate) fn run_workspace_sync(job_id: String) -> Result<()> { + workspace::run_sync(job_id) } async fn probe(request: DispatchProbeRequest) -> Result { @@ -111,19 +128,17 @@ async fn probe(request: DispatchProbeRequest) -> Result { "frontend_event_projection".to_string(), "append_message".to_string(), "event_log_completeness".to_string(), - "workspace_snapshot_exact".to_string(), - "workspace_snapshot_chunked".to_string(), + // Git-worktree delivery. A target without these cannot be provisioned + // at all — there is no snapshot fallback left — so controllers fail + // preflight rather than degrade. + "workspace_git_worktree".to_string(), + "workspace_git_bundle_upload".to_string(), + "workspace_git_sync".to_string(), // A target may share the same package version while predating the // dispatch entrypoint's early CLI-profile selection. Such a binary can // accept a job but every detached worker then fails before execution. // Advertise the behavioral fix explicitly so controllers fail closed. "dispatch_worker_cli_profile".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(), - // Identical snapshots from different jobs reuse one verified archive - // on the target. Jobs still receive independent writable workspaces. - "workspace_snapshot_cache".to_string(), ]; if runner::is_supported() { capabilities.push("detached_worker".to_string()); @@ -149,7 +164,11 @@ async fn submit(mut request: DispatchSubmitRequest) -> Result Result<()> { if request.prompt.len() > MAX_DISPATCH_TEXT_BYTES { bail!("dispatch prompt exceeds the 32 KiB request limit"); } + if request.setup_audit.len() > 32 { + bail!("dispatch setup audit exceeds the 32-event safety limit"); + } + for event in &request.setup_audit { + if event.action != "cli-install" { + bail!("dispatch setup audit contains an unsupported action"); + } + if event.timestamp.trim().is_empty() + || serde_json::to_vec(&event.details)?.len() > MAX_DISPATCH_TEXT_BYTES + { + bail!("dispatch setup audit event is invalid or too large"); + } + } Ok(()) } @@ -703,6 +735,7 @@ mod tests { approval_policy: DispatchApprovalPolicy::RejectAndReport, model: Some("model-1".to_string()), title: Some("Task".to_string()), + setup_audit: Vec::new(), } } diff --git a/src/apps/cli/src/dispatch/protocol.rs b/src/apps/cli/src/dispatch/protocol.rs index a20007fbba..d7f1ea04e7 100644 --- a/src/apps/cli/src/dispatch/protocol.rs +++ b/src/apps/cli/src/dispatch/protocol.rs @@ -1,9 +1,8 @@ use serde::{Deserialize, Serialize}; use bitfun_agent_runtime::sdk::{PermissionReply, PermissionRequest}; -use bitfun_services_core::dispatch_workspace::{WorkspaceResultSummary, WorkspaceSnapshotMetadata}; -pub(crate) const DISPATCH_PROTOCOL_VERSION: u32 = 2; +pub(crate) const DISPATCH_PROTOCOL_VERSION: u32 = 3; pub(crate) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024; #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] @@ -70,6 +69,21 @@ pub(crate) struct DispatchSubmitRequest { pub(crate) model: Option, #[serde(default)] pub(crate) title: Option, + /// Controller-side setup actions that happened before the target job could + /// exist (currently the signed CLI auto-install). They are replayed into + /// the durable job event log at creation time and are deliberately excluded + /// from submit idempotency. + #[serde(default)] + pub(crate) setup_audit: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchSetupAuditEvent { + pub(crate) timestamp: String, + pub(crate) action: String, + #[serde(default)] + pub(crate) details: serde_json::Value, } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -97,7 +111,7 @@ pub(crate) struct DispatchSubmitResponse { pub(crate) state: DispatchJobState, } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct DispatchStatusRequest { pub(crate) job_id: String, @@ -137,28 +151,69 @@ pub(crate) struct DispatchAppendResponse { pub(crate) message_id: String, } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +/// Ask the target to check out this dispatch's baseline commit. +/// +/// The target answers `needsBundle` when it cannot reach `baseCommit` from the +/// shared Git remote — an unpushed commit, or a repository with no remote at +/// all. The controller then delivers exactly the missing objects as a bundle +/// and retries, so the remote stays the fast path without being a requirement. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DispatchWorkspaceBeginRequest { +pub(crate) struct DispatchWorkspaceProvisionRequest { pub(crate) protocol_version: u32, pub(crate) job_id: String, - pub(crate) metadata: WorkspaceSnapshotMetadata, + /// Hex digest naming the shared clone on the target. Never a user-supplied + /// path: it becomes a directory name. + pub(crate) repo_key: String, + #[serde(default)] + pub(crate) remote_url: Option, + /// Full 40-character commit id. A ref name would be ambiguous — it can move + /// between the controller resolving it and the target fetching it. + pub(crate) base_commit: String, + pub(crate) branch: String, } -#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchWorkspaceProvisionResponse { + /// True while a detached target-side Git process is still running. The + /// controller polls the same idempotent verb; no individual RPC owns the + /// lifetime of clone/fetch/worktree creation. + #[serde(default)] + pub(crate) pending: bool, + pub(crate) provisioned: bool, + pub(crate) needs_bundle: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) workspace_path: Option, + pub(crate) base_commit: String, + pub(crate) branch: String, + /// Commits the target already has, so the controller can bundle only the + /// difference instead of the whole history. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) have_tips: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchWorkspaceBundleBeginRequest { + pub(crate) protocol_version: u32, + pub(crate) job_id: String, + pub(crate) sha256: String, + pub(crate) size: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub(crate) struct DispatchWorkspaceBeginResponse { +pub(crate) struct DispatchWorkspaceBundleBeginResponse { pub(crate) accepted: bool, + /// Bytes the target already holds, so a resumed upload skips them. pub(crate) offset: u64, - pub(crate) upload_path: String, pub(crate) committed: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) workspace_path: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DispatchWorkspaceChunkRequest { +pub(crate) struct DispatchWorkspaceBundleChunkRequest { pub(crate) job_id: String, pub(crate) offset: u64, pub(crate) data_base64: String, @@ -166,44 +221,77 @@ pub(crate) struct DispatchWorkspaceChunkRequest { #[derive(Clone, Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub(crate) struct DispatchWorkspaceChunkResponse { +pub(crate) struct DispatchWorkspaceBundleChunkResponse { pub(crate) accepted: bool, pub(crate) offset: u64, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DispatchWorkspaceCommitRequest { +pub(crate) struct DispatchWorkspaceBundleCommitRequest { pub(crate) job_id: String, } #[derive(Clone, Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub(crate) struct DispatchWorkspaceCommitResponse { +pub(crate) struct DispatchWorkspaceBundleCommitResponse { pub(crate) committed: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) workspace_path: Option, - pub(crate) metadata: WorkspaceSnapshotMetadata, + #[serde(default)] + pub(crate) pending: bool, } -/// Ask the target to diff its terminal tree against the delivered snapshot. +/// Commit the worktree and package its new history for the controller. /// -/// 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)] +/// Only ever appends commits on this job's own branch, so a controller that +/// never syncs leaves the target unchanged. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DispatchWorkspaceResultRequest { +pub(crate) struct DispatchWorkspaceSyncRequest { pub(crate) job_id: String, + /// Identifies one controller-side sync invocation. Every poll for that + /// invocation reuses this value, while a later user-requested sync gets a + /// new value even when `knownHead` has not advanced. + /// + /// The default keeps operation journals written by older v3 development + /// builds readable after an upgrade. New requests must still provide a + /// non-empty validated value. + #[serde(default)] + pub(crate) operation_id: String, + #[serde(default)] + pub(crate) message: Option, + /// Head the controller has already fetched. A later invocation combines + /// this boundary with a new `operationId` to start a new operation; + /// transport retries before acknowledgement receive the cached result. + #[serde(default)] + pub(crate) known_head: Option, } -#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[derive(Clone, Debug, Deserialize, 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, +pub(crate) struct DispatchWorkspaceSyncedChange { + pub(crate) status: String, + pub(crate) path: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchWorkspaceSyncResponse { + #[serde(default)] + pub(crate) pending: bool, + /// False when the worktree still matches `baseCommit`; no bundle is built. + pub(crate) changed: bool, + pub(crate) branch: String, + pub(crate) base_commit: String, + pub(crate) head_commit: String, + pub(crate) commit_count: u64, + pub(crate) changes: Vec, + /// True when the change list was capped; the bundle is still complete. + pub(crate) truncated_changes: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) bundle_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) bundle_sha256: Option, + pub(crate) bundle_size: u64, } /// Read a slice of an already-built result bundle. @@ -213,7 +301,7 @@ pub(crate) struct DispatchWorkspaceResultResponse { /// the same bytes back in chunks — the mirror of the upload path. #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DispatchWorkspaceResultChunkRequest { +pub(crate) struct DispatchWorkspaceSyncChunkRequest { pub(crate) job_id: String, pub(crate) offset: u64, pub(crate) length: u64, @@ -221,7 +309,7 @@ pub(crate) struct DispatchWorkspaceResultChunkRequest { #[derive(Clone, Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub(crate) struct DispatchWorkspaceResultChunkResponse { +pub(crate) struct DispatchWorkspaceSyncChunkResponse { pub(crate) offset: u64, pub(crate) data_base64: String, /// True once this chunk reaches the end of the bundle. @@ -268,6 +356,14 @@ pub(crate) enum DispatchEvent { } impl DispatchEvent { + pub(crate) fn setup_audit(event: DispatchSetupAuditEvent) -> Self { + Self::Audit { + timestamp: event.timestamp, + action: event.action, + details: event.details, + } + } + pub(crate) fn approval_policy_selected(policy: DispatchApprovalPolicy) -> Self { Self::Audit { timestamp: chrono::Utc::now().to_rfc3339(), diff --git a/src/apps/cli/src/dispatch/runner.rs b/src/apps/cli/src/dispatch/runner.rs index 24e4904f1c..0f4eec6aca 100644 --- a/src/apps/cli/src/dispatch/runner.rs +++ b/src/apps/cli/src/dispatch/runner.rs @@ -19,11 +19,27 @@ pub(crate) fn spawn(store: &DispatchStore, job_id: &str) -> Result { result } -pub(crate) fn spawn_workspace_materializer(job_id: &str) -> Result { +pub(crate) fn spawn_workspace_provision(job_id: &str) -> Result { spawn_detached_action( - "__workspace_materialize", + "__workspace_provision_run", job_id, - "dispatch workspace materializer", + "dispatch workspace provisioner", + ) +} + +pub(crate) fn spawn_workspace_bundle_commit(job_id: &str) -> Result { + spawn_detached_action( + "__workspace_bundle_commit_run", + job_id, + "dispatch bundle importer", + ) +} + +pub(crate) fn spawn_workspace_sync(job_id: &str) -> Result { + spawn_detached_action( + "__workspace_sync_run", + job_id, + "dispatch workspace synchronizer", ) } @@ -60,6 +76,10 @@ pub(crate) fn worker_process_alive(pid: u32, job_id: &str) -> bool { process_alive(pid) && process_matches_job(pid, job_id) } +pub(crate) fn workspace_operation_process_alive(pid: u32, action: &str, job_id: &str) -> bool { + process_alive(pid) && process_matches_action(pid, action, job_id) +} + pub(crate) fn worker_process_group_alive(pid: u32) -> bool { #[cfg(unix)] { @@ -216,6 +236,11 @@ pub(crate) fn process_alive(_pid: u32) -> bool { #[cfg(target_os = "linux")] fn process_matches_job(pid: u32, job_id: &str) -> bool { + process_matches_action(pid, "__run", job_id) +} + +#[cfg(target_os = "linux")] +fn process_matches_action(pid: u32, action: &str, job_id: &str) -> bool { let Ok(raw) = std::fs::read(format!("/proc/{pid}/cmdline")) else { return false; }; @@ -224,11 +249,16 @@ fn process_matches_job(pid: u32, job_id: &str) -> bool { .filter(|arg| !arg.is_empty()) .map(|arg| String::from_utf8_lossy(arg).into_owned()) .collect::>(); - arguments_match_job(&args, job_id) + arguments_match_action(&args, action, job_id) } #[cfg(target_os = "macos")] fn process_matches_job(pid: u32, job_id: &str) -> bool { + process_matches_action(pid, "__run", job_id) +} + +#[cfg(target_os = "macos")] +fn process_matches_action(pid: u32, action: &str, job_id: &str) -> bool { let output = Command::new("ps") .args(["-p", &pid.to_string(), "-o", "command="]) .output(); @@ -243,7 +273,7 @@ fn process_matches_job(pid: u32, job_id: &str) -> bool { .split_whitespace() .map(ToOwned::to_owned) .collect::>(); - arguments_match_job(&args, job_id) + arguments_match_action(&args, action, job_id) } #[cfg(not(any(target_os = "linux", target_os = "macos")))] @@ -251,10 +281,15 @@ fn process_matches_job(_pid: u32, _job_id: &str) -> bool { false } -fn arguments_match_job(args: &[String], job_id: &str) -> bool { +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn process_matches_action(_pid: u32, _action: &str, _job_id: &str) -> bool { + false +} + +fn arguments_match_action(args: &[String], action: &str, job_id: &str) -> bool { args.windows(4).any(|window| { window[0] == "dispatch" - && window[1] == "__run" + && window[1] == action && window[2] == "--job" && window[3] == job_id }) @@ -280,10 +315,37 @@ mod tests { #[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")); + assert!(arguments_match_action(&expected, "__run", "job-1")); + assert!(!arguments_match_action(&expected, "__run", "job-2")); let unrelated = ["bitfun", "dispatch", "status"].map(str::to_string); - assert!(!arguments_match_job(&unrelated, "job-1")); + assert!(!arguments_match_action(&unrelated, "__run", "job-1")); + } + + #[test] + fn workspace_operation_identity_requires_the_exact_hidden_action() { + let expected = [ + "bitfun", + "dispatch", + "__workspace_sync_run", + "--job", + "job-1", + ] + .map(str::to_string); + assert!(arguments_match_action( + &expected, + "__workspace_sync_run", + "job-1" + )); + assert!(!arguments_match_action( + &expected, + "__workspace_provision_run", + "job-1" + )); + assert!(!arguments_match_action( + &expected, + "__workspace_sync_run", + "job-2" + )); } #[cfg(unix)] diff --git a/src/apps/cli/src/dispatch/store.rs b/src/apps/cli/src/dispatch/store.rs index 890db34966..fa993ef0d2 100644 --- a/src/apps/cli/src/dispatch/store.rs +++ b/src/apps/cli/src/dispatch/store.rs @@ -39,9 +39,12 @@ const TERMINAL_JOB_RETENTION_DAYS: i64 = 30; const RETENTION_GC_INTERVAL_SECONDS: u64 = 24 * 60 * 60; const RETENTION_GC_MARKER: &str = ".retention-gc"; const RETENTION_GC_LOCK: &str = ".retention-gc.lock"; -const WORKSPACE_SNAPSHOT_CACHE_DIR: &str = "workspace-cache"; -pub(super) const WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE: &str = "cache.json"; -const WORKSPACE_SNAPSHOT_CACHE_RETENTION_DAYS: i64 = 30; +/// Shared bare clones, one per source repository, reused across dispatch jobs. +const DISPATCH_REPOS_DIR: &str = "repos"; +/// Per-job Git worktrees checked out from those clones. +const DISPATCH_WORKTREES_DIR: &str = "worktrees"; +pub(super) const REPO_CACHE_RECORD_FILE: &str = "repo.json"; +const REPO_CACHE_RETENTION_DAYS: i64 = 30; #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -133,7 +136,7 @@ struct StoredAppendMessage { #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct WorkspaceSnapshotCacheRetentionRecord { +struct RepoCacheRetentionRecord { last_used_at: String, } @@ -158,7 +161,8 @@ impl DispatchStore { create_private_dir(&root)?; create_private_dir(&root.join("jobs"))?; create_private_dir(&root.join("workspaces"))?; - create_private_dir(&root.join(WORKSPACE_SNAPSHOT_CACHE_DIR))?; + create_private_dir(&root.join(DISPATCH_REPOS_DIR))?; + create_private_dir(&root.join(DISPATCH_WORKTREES_DIR))?; Ok(Self { root, max_events_bytes: DEFAULT_MAX_EVENTS_BYTES, @@ -227,6 +231,9 @@ impl DispatchStore { &job_dir.join(EVENTS_METADATA_FILE), &EventLogMetadata::default(), )?; + for event in record.request.setup_audit.iter().cloned() { + self.append_event_unlocked(&job_dir, &DispatchEvent::setup_audit(event))?; + } self.append_event_unlocked( &job_dir, &DispatchEvent::approval_policy_selected(record.request.approval_policy), @@ -838,8 +845,25 @@ impl DispatchStore { .join(format!("{digest:x}.lock")) } - pub(crate) fn root(&self) -> &Path { - &self.root + /// Serializes the small JSON state machine used to start and poll one + /// detached Git operation. It must never be held while Git itself runs, + /// otherwise a status RPC blocks behind the worker it is meant to poll. + pub(crate) fn workspace_operation_lock_path(&self, job_id: &str) -> Result { + validate_id("jobId", job_id)?; + Ok(self + .root + .join("workspaces") + .join(format!(".{job_id}.workspace.lock"))) + } + + /// Serializes long-running Git mutations for one managed dispatch + /// worktree. Retention uses the same lock before removing its artifacts. + pub(crate) fn workspace_git_operation_lock_path(&self, job_id: &str) -> Result { + validate_id("jobId", job_id)?; + Ok(self + .root + .join("workspaces") + .join(format!(".{job_id}.git.lock"))) } pub(crate) fn workspace_upload_dir(&self, job_id: &str) -> Result { @@ -847,8 +871,41 @@ impl DispatchStore { Ok(self.root.join("workspaces").join(job_id)) } - pub(crate) fn workspace_snapshot_cache_root(&self) -> PathBuf { - self.root.join(WORKSPACE_SNAPSHOT_CACHE_DIR) + pub(crate) fn repos_root(&self) -> PathBuf { + self.root.join(DISPATCH_REPOS_DIR) + } + + /// Directory holding one shared clone. `repo_key` is validated by the + /// workspace layer before it ever reaches here, but re-checking costs + /// nothing and keeps the path constructor safe on its own. + pub(crate) fn repo_dir(&self, repo_key: &str) -> Result { + if repo_key.len() < 8 + || repo_key.len() > 64 + || !repo_key.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + bail!("dispatch repoKey must be an 8-64 character hex digest"); + } + Ok(self.repos_root().join(repo_key)) + } + + /// Cross-process lock for every Git operation that touches one shared + /// bare repository. Provision, bundle import, sync, and retention all use + /// the same lock so two jobs cannot concurrently mutate refs or race a + /// repository-cache deletion. + pub(crate) fn repo_lock_path(&self, repo_key: &str) -> Result { + // Reuse the path validation performed by `repo_dir` before deriving a + // sibling lock-file name from the key. + self.repo_dir(repo_key)?; + Ok(self.repos_root().join(format!(".{repo_key}.lock"))) + } + + pub(crate) fn worktrees_root(&self) -> PathBuf { + self.root.join(DISPATCH_WORKTREES_DIR) + } + + pub(crate) fn worktree_dir(&self, job_id: &str) -> Result { + validate_id("jobId", job_id)?; + Ok(self.worktrees_root().join(job_id)) } fn maybe_collect_expired_terminal_jobs(&self) -> Result<()> { @@ -919,6 +976,32 @@ impl DispatchStore { if now.signed_duration_since(finished_at).num_days() < TERMINAL_JOB_RETENTION_DAYS { continue; } + let Some(operation_lock) = + JobLock::try_exclusive(&self.workspace_operation_lock_path(&job_id)?)? + else { + continue; + }; + let Some(git_operation_lock) = + JobLock::try_exclusive(&self.workspace_git_operation_lock_path(&job_id)?)? + else { + continue; + }; + let job_record: DispatchJobRecord = match read_json(&job_dir.join(JOB_RECORD_FILE)) { + Ok(record) => record, + Err(error) => { + tracing::warn!( + "Skipping dispatch job with unreadable workspace binding during retention cleanup: job_id={} error={error:#}", + job_id + ); + continue; + } + }; + let Some(workspace_runtime_lock) = WorkspaceLock::try_acquire( + &self.workspace_lock_path(&job_record.request.workspace_path), + )? + else { + continue; + }; let tombstone = jobs_root.join(format!( ".gc-{}-{}", job_id, @@ -973,28 +1056,10 @@ impl DispatchStore { Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(error.into()), } - let upload_lock = self - .root - .join("workspaces") - .join(format!(".{job_id}.upload.lock")); - match fs::symlink_metadata(&upload_lock) { - Ok(metadata) if !metadata.file_type().is_symlink() && metadata.is_file() => { - fs::remove_file(&upload_lock).with_context(|| { - format!( - "remove expired dispatch workspace lock {}", - upload_lock.display() - ) - })?; - } - Ok(_) => { - tracing::warn!( - "Skipping unsafe expired dispatch workspace lock: {}", - upload_lock.display() - ); - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(error.into()), - } + drop(workspace_runtime_lock); + drop(git_operation_lock); + drop(operation_lock); + self.remove_workspace_operation_locks(&job_id)?; removed += 1; } let workspaces_root = self.root.join("workspaces"); @@ -1023,6 +1088,16 @@ impl DispatchStore { if !old_enough { continue; } + let Some(operation_lock) = + JobLock::try_exclusive(&self.workspace_operation_lock_path(&job_id)?)? + else { + continue; + }; + let Some(git_operation_lock) = + JobLock::try_exclusive(&self.workspace_git_operation_lock_path(&job_id)?)? + else { + continue; + }; let tombstone = workspaces_root.join(format!( ".gc-{}-{}", job_id, @@ -1037,25 +1112,106 @@ impl DispatchStore { fs::remove_dir_all(&tombstone).with_context(|| { format!("remove orphaned dispatch workspace {}", tombstone.display()) })?; + drop(git_operation_lock); + drop(operation_lock); + self.remove_workspace_operation_locks(&job_id)?; removed += 1; } - self.collect_expired_workspace_snapshot_cache(now)?; + removed += self.collect_orphaned_worktrees(&jobs_root)?; + self.collect_expired_repo_clones(now)?; Ok(removed) } - fn collect_expired_workspace_snapshot_cache( - &self, - now: chrono::DateTime, - ) -> Result<()> { - let cache_root = self.workspace_snapshot_cache_root(); + /// Remove worktrees whose job record is gone. + /// + /// The directory is only the checkout: every commit made in it was fetched + /// into the shared clone during sync, so removing it cannot lose work that + /// the controller pulled. Work the controller never pulled is discarded + /// along with the job it belonged to, which is the same retention promise + /// the event log makes. + /// + /// Stale worktree administrative entries left inside the clone are pruned + /// by the next provision, which always runs `git worktree prune` first. + fn collect_orphaned_worktrees(&self, jobs_root: &Path) -> Result { + let worktrees_root = self.worktrees_root(); + let mut removed = 0; + for entry in fs::read_dir(&worktrees_root) + .with_context(|| format!("read dispatch worktrees {}", worktrees_root.display()))? + { + let entry = entry?; + let Some(job_id) = entry.file_name().to_str().map(ToOwned::to_owned) else { + continue; + }; + if validate_id("jobId", &job_id).is_err() || jobs_root.join(&job_id).exists() { + continue; + } + let worktree_dir = entry.path(); + let metadata = fs::symlink_metadata(&worktree_dir)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + continue; + } + let old_enough = metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|elapsed| { + elapsed.as_secs() >= (TERMINAL_JOB_RETENTION_DAYS as u64) * 24 * 60 * 60 + }); + if !old_enough { + continue; + } + let Some(operation_lock) = + JobLock::try_exclusive(&self.workspace_operation_lock_path(&job_id)?)? + else { + continue; + }; + let Some(git_operation_lock) = + JobLock::try_exclusive(&self.workspace_git_operation_lock_path(&job_id)?)? + else { + continue; + }; + let canonical_worktree = std::fs::canonicalize(&worktree_dir) + .unwrap_or_else(|_| worktree_dir.clone()) + .to_string_lossy() + .to_string(); + let Some(workspace_runtime_lock) = + WorkspaceLock::try_acquire(&self.workspace_lock_path(&canonical_worktree))? + else { + continue; + }; + let tombstone = worktrees_root.join(format!( + ".gc-{}-{}", + job_id, + uuid::Uuid::new_v4().as_simple() + )); + fs::rename(&worktree_dir, &tombstone).with_context(|| { + format!( + "quarantine orphaned dispatch worktree {}", + worktree_dir.display() + ) + })?; + fs::remove_dir_all(&tombstone).with_context(|| { + format!("remove orphaned dispatch worktree {}", tombstone.display()) + })?; + drop(workspace_runtime_lock); + drop(git_operation_lock); + drop(operation_lock); + self.remove_workspace_operation_locks(&job_id)?; + removed += 1; + } + Ok(removed) + } + + fn collect_expired_repo_clones(&self, now: chrono::DateTime) -> Result<()> { + let cache_root = self.repos_root(); for entry in fs::read_dir(&cache_root) - .with_context(|| format!("read dispatch workspace cache {}", cache_root.display()))? + .with_context(|| format!("read dispatch repository cache {}", cache_root.display()))? { let entry = entry?; let Some(digest) = entry.file_name().to_str().map(ToOwned::to_owned) else { continue; }; - if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + if self.repo_dir(&digest).is_err() { continue; } let cache_dir = entry.path(); @@ -1063,12 +1219,15 @@ impl DispatchStore { if metadata.file_type().is_symlink() || !metadata.is_dir() { continue; } - let lock_path = cache_root.join(format!(".{digest}.lock")); + let lock_path = self.repo_lock_path(&digest)?; let Some(_lock) = JobLock::try_exclusive(&lock_path)? else { continue; }; - let record = match read_json::( - &cache_dir.join(WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE), + if self.repo_cache_is_referenced(&digest)? { + continue; + } + let record = match read_json::( + &cache_dir.join(REPO_CACHE_RECORD_FILE), ) { Ok(record) => record, Err(error) => { @@ -1085,9 +1244,7 @@ impl DispatchStore { else { continue; }; - if now.signed_duration_since(last_used_at).num_days() - < WORKSPACE_SNAPSHOT_CACHE_RETENTION_DAYS - { + if now.signed_duration_since(last_used_at).num_days() < REPO_CACHE_RETENTION_DAYS { continue; } let tombstone = cache_root.join(format!( @@ -1111,6 +1268,61 @@ impl DispatchStore { Ok(()) } + /// A live job keeps its shared bare repository alive even when it has not + /// performed a Git operation for longer than the cache retention window. + /// Its worktree's Git metadata points into that repository, so collecting + /// the clone would otherwise break an in-flight detached task. + fn repo_cache_is_referenced(&self, repo_key: &str) -> Result { + let workspaces_root = self.root.join("workspaces"); + let jobs_root = self.root.join("jobs"); + for entry in fs::read_dir(&workspaces_root) + .with_context(|| format!("read dispatch workspaces {}", workspaces_root.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; + }; + if validate_id("jobId", &job_id).is_err() || !jobs_root.join(&job_id).is_dir() { + continue; + } + let provision_path = entry.path().join("provision.json"); + let Ok(value) = read_json::(&provision_path) else { + continue; + }; + if value.get("repoKey").and_then(serde_json::Value::as_str) == Some(repo_key) { + return Ok(true); + } + } + Ok(false) + } + + fn remove_workspace_operation_locks(&self, job_id: &str) -> Result<()> { + validate_id("jobId", job_id)?; + for suffix in ["upload", "workspace", "git"] { + let path = self + .root + .join("workspaces") + .join(format!(".{job_id}.{suffix}.lock")); + match fs::symlink_metadata(&path) { + Ok(metadata) if !metadata.file_type().is_symlink() && metadata.is_file() => { + fs::remove_file(&path).with_context(|| { + format!("remove expired dispatch operation lock {}", path.display()) + })?; + } + Ok(_) => tracing::warn!( + "Skipping unsafe expired dispatch operation lock: {}", + path.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) + } + fn load_state_unlocked(&self, job_dir: &Path) -> Result { read_json(&job_dir.join(STATE_FILE)) } @@ -1229,6 +1441,21 @@ impl WorkspaceLock { FileLock::exclusive(&file)?; Ok(Self { _file: file }) } + + pub(crate) fn try_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)?; + try_lock_file_exclusive(&file).map(|acquired| acquired.then_some(Self { _file: file })) + } } pub(crate) struct DispatchLease { @@ -1690,7 +1917,9 @@ fn retryable_retention_rename_error(error: &std::io::Error) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::dispatch::protocol::{DispatchApprovalPolicy, DispatchSubmitRequest}; + use crate::dispatch::protocol::{ + DispatchApprovalPolicy, DispatchSetupAuditEvent, DispatchSubmitRequest, + }; use bitfun_agent_runtime::sdk::{PermissionRequestSource, PermissionRequestSourceKind}; use serde_json::Map; @@ -1705,6 +1934,7 @@ mod tests { approval_policy: DispatchApprovalPolicy::RejectAndReport, model: Some("model-1".to_string()), title: None, + setup_audit: Vec::new(), } } @@ -2128,6 +2358,32 @@ mod tests { assert!(details.get("prompt").is_none()); } + #[test] + fn controller_setup_audit_is_replayed_before_target_job_events() { + let (_dir, store) = store(); + let mut request = request("job-setup-audit"); + request.setup_audit.push(DispatchSetupAuditEvent { + timestamp: "2026-07-31T00:00:00Z".to_string(), + action: "cli-install".to_string(), + details: serde_json::json!({ "stage": "cli-install-succeeded" }), + }); + store + .create_job(request, "Task".to_string()) + .expect("create job"); + + let page = store.read_events("job-setup-audit", 0).expect("events"); + assert!(matches!( + &page.events[0], + DispatchEvent::Audit { action, details, .. } + if action == "cli-install" + && details["stage"] == "cli-install-succeeded" + )); + assert!(matches!( + &page.events[1], + DispatchEvent::Audit { action, .. } if action == "approvalPolicySelected" + )); + } + #[test] fn cursor_beyond_the_file_resets_to_the_retained_prefix() { let (_dir, store) = store(); @@ -2475,15 +2731,14 @@ mod tests { for (digest, last_used_at) in [ ( &expired_digest, - (now - chrono::Duration::days(WORKSPACE_SNAPSHOT_CACHE_RETENTION_DAYS + 1)) - .to_rfc3339(), + (now - chrono::Duration::days(REPO_CACHE_RETENTION_DAYS + 1)).to_rfc3339(), ), (&recent_digest, now.to_rfc3339()), ] { - let cache_dir = store.workspace_snapshot_cache_root().join(digest); + let cache_dir = store.repos_root().join(digest); create_private_dir(&cache_dir).expect("create cache entry"); atomic_write_json( - &cache_dir.join(WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE), + &cache_dir.join(REPO_CACHE_RECORD_FILE), &serde_json::json!({ "lastUsedAt": last_used_at }), ) .expect("write cache record"); @@ -2497,18 +2752,20 @@ mod tests { ); assert!(!store.root.join("jobs/expired").exists()); assert!(!store.root.join("workspaces/expired").exists()); + assert!(!store + .workspace_operation_lock_path("expired") + .expect("operation lock path") + .exists()); + assert!(!store + .workspace_git_operation_lock_path("expired") + .expect("git operation lock path") + .exists()); assert!(store.root.join("jobs/recent").exists()); assert!(store.root.join("workspaces/recent").exists()); assert!(store.root.join("jobs/running").exists()); assert!(store.root.join("workspaces/running").exists()); - assert!(!store - .workspace_snapshot_cache_root() - .join(expired_digest) - .exists()); - assert!(store - .workspace_snapshot_cache_root() - .join(recent_digest) - .exists()); + assert!(!store.repos_root().join(expired_digest).exists()); + assert!(store.repos_root().join(recent_digest).exists()); } #[test] diff --git a/src/apps/cli/src/dispatch/workspace.rs b/src/apps/cli/src/dispatch/workspace.rs index 2afe626435..7630f2c58b 100644 --- a/src/apps/cli/src/dispatch/workspace.rs +++ b/src/apps/cli/src/dispatch/workspace.rs @@ -1,777 +1,1476 @@ +//! Target-side Git workspace provisioning and result sync. +//! +//! A dispatch runs in a Git worktree of the controller's repository, checked out +//! here at the exact commit the controller branched from. Objects normally +//! arrive from the shared Git remote; only what the remote does not have — +//! unpushed commits, or every object when the repository has no remote at all — +//! is carried over the wire as a Git bundle. +//! +//! Results travel the same way in reverse: the worktree's commits become a +//! bundle the controller fetches into its own baseline worktree. Because both +//! sides share `base_commit`, that fetch is an ordinary fast-forward rather than +//! a file-by-file overwrite. + use std::fs::{self, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use anyhow::{bail, Context, Result}; use base64::Engine as _; -use bitfun_services_core::dispatch_workspace::{ - create_workspace_result_bundle, extract_workspace_snapshot, sha256_file, - WorkspaceSnapshotManifest, WorkspaceSnapshotMetadata, MAX_SNAPSHOT_ARCHIVE_BYTES, - MAX_SNAPSHOT_DIRECTORIES, MAX_SNAPSHOT_FILES, MAX_SNAPSHOT_UNCOMPRESSED_BYTES, - WORKSPACE_SNAPSHOT_FORMAT_VERSION, -}; +use bitfun_services_core::dispatch_workspace::sha256_file; use serde::{Deserialize, Serialize}; use super::protocol::{ - DispatchWorkspaceBeginRequest, DispatchWorkspaceBeginResponse, DispatchWorkspaceChunkRequest, - DispatchWorkspaceChunkResponse, DispatchWorkspaceCommitRequest, - DispatchWorkspaceCommitResponse, DispatchWorkspaceResultChunkRequest, - DispatchWorkspaceResultChunkResponse, DispatchWorkspaceResultRequest, - DispatchWorkspaceResultResponse, DISPATCH_PROTOCOL_VERSION, + DispatchWorkspaceBundleBeginRequest, DispatchWorkspaceBundleBeginResponse, + DispatchWorkspaceBundleChunkRequest, DispatchWorkspaceBundleChunkResponse, + DispatchWorkspaceBundleCommitRequest, DispatchWorkspaceBundleCommitResponse, + DispatchWorkspaceProvisionRequest, DispatchWorkspaceProvisionResponse, + DispatchWorkspaceSyncChunkRequest, DispatchWorkspaceSyncChunkResponse, + DispatchWorkspaceSyncRequest, DispatchWorkspaceSyncResponse, DispatchWorkspaceSyncedChange, + DISPATCH_PROTOCOL_VERSION, }; use super::store::{ atomic_write_json, create_private_dir, read_json, remove_file_if_present, set_private_file_permissions, sync_directory, DispatchStore, JobLock, - WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE, }; -const UPLOAD_RECORD_FILE: &str = "upload.json"; -const UPLOAD_ARCHIVE_FILE: &str = "workspace.tar.gz"; -const CACHE_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 PROVISION_RECORD_FILE: &str = "provision.json"; +const PROVISION_OPERATION_FILE: &str = "provision-operation.json"; +const BUNDLE_RECORD_FILE: &str = "bundle.json"; +const SYNC_OPERATION_FILE: &str = "sync-operation.json"; +const INCOMING_BUNDLE_FILE: &str = "incoming.bundle"; +const RESULT_BUNDLE_FILE: &str = "result.bundle"; const MAX_CHUNK_BYTES: usize = 256 * 1024; const MAX_CHUNK_BASE64_BYTES: usize = 384 * 1024; -const MAX_MATERIALIZATION_ERROR_BYTES: usize = 16 * 1024; +/// Ceiling for one delivered bundle. Generous for source history, small enough +/// that a hostile or broken controller cannot fill the target's disk. +const MAX_BUNDLE_BYTES: u64 = 2 * 1024 * 1024 * 1024; +/// Cap on the change list reported after a sync, so a huge refactor cannot push +/// the response past the smallest host transport envelope. +const MAX_REPORTED_CHANGES: usize = 2_000; +const DEFAULT_SYNC_COMMIT_MESSAGE: &str = "BitFun dispatch result"; +/// A freshly spawned child may not have published an inspectable process +/// identity by the first poll. Keep that tiny start window from spawning a +/// duplicate worker while still allowing a dead child to be recovered. +const OPERATION_START_GRACE_SECONDS: i64 = 10; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -enum WorkspaceUploadState { +enum BundleUploadState { Uploading, + Committing, Committed, Failed, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -struct WorkspaceUploadRecord { +struct BundleUploadRecord { protocol_version: u32, job_id: String, - metadata: WorkspaceSnapshotMetadata, - state: WorkspaceUploadState, + sha256: String, + size: u64, + state: BundleUploadState, created_at: String, #[serde(default)] - committed_at: Option, + worker_pid: Option, #[serde(default)] - workspace_path: Option, + last_error: Option, + #[serde(default)] + updated_at: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum WorkspaceOperationState { + Pending, + Running, + Succeeded, + Failed, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProvisionOperationRecord { + request: DispatchWorkspaceProvisionRequest, + state: WorkspaceOperationState, + #[serde(default)] + worker_pid: Option, + #[serde(default)] + response: Option, + #[serde(default)] + last_error: Option, + updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SyncOperationRecord { + request: DispatchWorkspaceSyncRequest, + state: WorkspaceOperationState, + #[serde(default)] + worker_pid: Option, + #[serde(default)] + response: Option, #[serde(default)] last_error: Option, + /// True only after the retained failure diagnostic was returned to a + /// controller. A later operation may replace that failed generation once + /// its worker is also gone. + #[serde(default)] + failure_reported: bool, + updated_at: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -struct WorkspaceSnapshotCacheRecord { - metadata: WorkspaceSnapshotMetadata, +struct ProvisionRecord { + protocol_version: u32, + job_id: String, + repo_key: String, + #[serde(default)] + remote_url: Option, + base_commit: String, + branch: String, created_at: String, - last_used_at: String, + #[serde(default)] + workspace_path: Option, +} + +/// Touch file that keeps a shared clone from being collected while in use. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RepoCacheRecord { + #[serde(default)] + pub(super) remote_url: Option, + pub(super) created_at: String, + pub(super) last_used_at: String, } -pub(crate) fn begin( - request: DispatchWorkspaceBeginRequest, -) -> Result { +pub(crate) fn provision( + request: DispatchWorkspaceProvisionRequest, +) -> Result { + validate_provision(&request)?; let store = DispatchStore::open_default()?; - begin_in_store(&store, request) + let job_dir = store.workspace_upload_dir(&request.job_id)?; + create_private_dir(&job_dir)?; + let _lock = JobLock::exclusive(&store.workspace_operation_lock_path(&request.job_id)?)?; + let operation_path = job_dir.join(PROVISION_OPERATION_FILE); + let mut operation = match read_optional_json::(&operation_path)? { + Some(existing) => { + if existing.request != request { + bail!("dispatch job is already bound to a different Git baseline"); + } + existing + } + None => ProvisionOperationRecord { + request: request.clone(), + state: WorkspaceOperationState::Pending, + worker_pid: None, + response: None, + last_error: None, + updated_at: chrono::Utc::now().to_rfc3339(), + }, + }; + + if operation.state == WorkspaceOperationState::Succeeded { + let response = operation + .response + .clone() + .context("dispatch provision operation has no response")?; + let bundle_committed = + read_optional_json::(&job_dir.join(BUNDLE_RECORD_FILE))? + .is_some_and(|record| record.state == BundleUploadState::Committed); + if !response.needs_bundle || !bundle_committed { + return Ok(response); + } + // The first pass asked for objects and the upload is now committed. + // Re-run the same immutable request to publish the worktree. + operation.state = WorkspaceOperationState::Pending; + operation.worker_pid = None; + operation.response = None; + operation.last_error = None; + } else if operation.state == WorkspaceOperationState::Failed { + let diagnostic = operation + .last_error + .clone() + .unwrap_or_else(|| "target retained no diagnostic".to_string()); + // Surface this attempt's failure once, but leave a retryable marker so + // the next controller action can recover from transient Git/IO errors. + operation.state = WorkspaceOperationState::Pending; + operation.worker_pid = None; + operation.response = None; + operation.last_error = None; + operation.updated_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json(&operation_path, &operation)?; + bail!("dispatch workspace provisioning failed: {diagnostic}"); + } else if operation.worker_pid.is_some_and(|pid| { + workspace_worker_is_active( + pid, + "__workspace_provision_run", + &request.job_id, + &operation.updated_at, + ) + }) { + return Ok(pending_provision_response(&request)); + } + + operation.state = WorkspaceOperationState::Pending; + operation.worker_pid = None; + operation.updated_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json(&operation_path, &operation)?; + match super::runner::spawn_workspace_provision(&request.job_id) { + Ok(pid) => { + operation.worker_pid = Some(pid); + operation.updated_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json(&operation_path, &operation)?; + Ok(pending_provision_response(&request)) + } + Err(error) => { + operation.state = WorkspaceOperationState::Failed; + operation.last_error = Some(truncate_utf8(&format!("{error:#}"))); + operation.updated_at = chrono::Utc::now().to_rfc3339(); + let _ = atomic_write_json(&operation_path, &operation); + Err(error) + } + } +} + +/// Detached half of `workspace-provision`. +pub(crate) fn run_provision(job_id: String) -> Result<()> { + let store = DispatchStore::open_default()?; + let job_dir = store.workspace_upload_dir(&job_id)?; + let operation_path = job_dir.join(PROVISION_OPERATION_FILE); + { + let _lock = JobLock::exclusive(&store.workspace_operation_lock_path(&job_id)?)?; + let mut operation: ProvisionOperationRecord = read_json(&operation_path) + .context("dispatch workspace provision operation was not initialized")?; + if operation.state == WorkspaceOperationState::Succeeded { + return Ok(()); + } + operation.state = WorkspaceOperationState::Running; + operation.worker_pid = Some(std::process::id()); + operation.updated_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json(&operation_path, &operation)?; + } + + let request: DispatchWorkspaceProvisionRequest = + read_json::(&operation_path)?.request; + let outcome = provision_in_store(&store, request); + let _lock = JobLock::exclusive(&store.workspace_operation_lock_path(&job_id)?)?; + let mut operation: ProvisionOperationRecord = read_json(&operation_path)?; + operation.worker_pid = None; + operation.updated_at = chrono::Utc::now().to_rfc3339(); + match outcome { + Ok(response) => { + operation.state = WorkspaceOperationState::Succeeded; + operation.response = Some(response); + operation.last_error = None; + atomic_write_json(&operation_path, &operation) + } + Err(error) => { + operation.state = WorkspaceOperationState::Failed; + operation.last_error = Some(truncate_utf8(&format!("{error:#}"))); + atomic_write_json(&operation_path, &operation)?; + Err(error) + } + } } -fn begin_in_store( +fn pending_provision_response( + request: &DispatchWorkspaceProvisionRequest, +) -> DispatchWorkspaceProvisionResponse { + DispatchWorkspaceProvisionResponse { + pending: true, + provisioned: false, + needs_bundle: false, + workspace_path: None, + base_commit: request.base_commit.clone(), + branch: request.branch.clone(), + have_tips: Vec::new(), + } +} + +fn provision_in_store( store: &DispatchStore, - request: DispatchWorkspaceBeginRequest, -) -> Result { - validate_begin(&request)?; - let upload_dir = store.workspace_upload_dir(&request.job_id)?; - let lock_path = workspace_upload_lock_path(store, &request.job_id); - let Some(_lock) = JobLock::try_exclusive(&lock_path)? else { - let existing: WorkspaceUploadRecord = read_json(&upload_dir.join(UPLOAD_RECORD_FILE)) - .context("workspace upload is currently being initialized")?; - ensure_begin_binding(&existing, &request)?; - ensure_upload_not_failed(&existing)?; - if existing.state == WorkspaceUploadState::Committed { - let workspace_path = - validate_committed_workspace(&upload_dir, existing.workspace_path.as_deref())?; - return Ok(DispatchWorkspaceBeginResponse { - accepted: true, - offset: request.metadata.archive_size, - upload_path: upload_dir - .join(UPLOAD_ARCHIVE_FILE) - .to_string_lossy() - .to_string(), - committed: true, - workspace_path: Some(workspace_path), - }); + request: DispatchWorkspaceProvisionRequest, +) -> Result { + validate_provision(&request)?; + let job_dir = store.workspace_upload_dir(&request.job_id)?; + let _lock = JobLock::exclusive(&store.workspace_git_operation_lock_path(&request.job_id)?)?; + create_private_dir(&job_dir)?; + + let record_path = job_dir.join(PROVISION_RECORD_FILE); + match read_json::(&record_path) { + Ok(existing) => ensure_provision_binding(&existing, &request)?, + Err(_) => { + atomic_write_json( + &record_path, + &ProvisionRecord { + protocol_version: request.protocol_version, + job_id: request.job_id.clone(), + repo_key: request.repo_key.clone(), + remote_url: request.remote_url.clone(), + base_commit: request.base_commit.clone(), + branch: request.branch.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + workspace_path: None, + }, + )?; } - let archive_path = upload_dir.join(UPLOAD_ARCHIVE_FILE); - let offset = fs::symlink_metadata(&archive_path) - .ok() - .filter(|metadata| !metadata.file_type().is_symlink() && metadata.is_file()) - .map(|metadata| metadata.len().min(request.metadata.archive_size)) - .unwrap_or(0); - return Ok(DispatchWorkspaceBeginResponse { - accepted: true, - offset, - upload_path: archive_path.to_string_lossy().to_string(), - committed: false, - workspace_path: None, + } + + let _repo_lock = JobLock::exclusive(&store.repo_lock_path(&request.repo_key)?)?; + + let worktree_path = store.worktree_dir(&request.job_id)?; + if let Some(existing) = + existing_worktree(&worktree_path, &request.branch, &request.base_commit)? + { + return Ok(DispatchWorkspaceProvisionResponse { + pending: false, + provisioned: true, + needs_bundle: false, + workspace_path: Some(existing), + base_commit: request.base_commit, + branch: request.branch, + have_tips: Vec::new(), }); - }; + } - let record_path = upload_dir.join(UPLOAD_RECORD_FILE); - if let Ok(existing) = read_json::(&record_path) { - ensure_begin_binding(&existing, &request)?; - ensure_upload_not_failed(&existing)?; - if existing.state == WorkspaceUploadState::Committed { - let workspace_path = - validate_committed_workspace(&upload_dir, existing.workspace_path.as_deref())?; - return Ok(DispatchWorkspaceBeginResponse { - accepted: true, - offset: request.metadata.archive_size, - upload_path: upload_dir - .join(UPLOAD_ARCHIVE_FILE) - .to_string_lossy() - .to_string(), - committed: true, - workspace_path: Some(workspace_path), - }); + let repo = ensure_repository(store, &request.repo_key, request.remote_url.as_deref())?; + if request.remote_url.is_some() && !commit_exists(&repo, &request.base_commit)? { + // A fetch failure is not fatal on its own: the controller can still + // deliver the missing objects by bundle, which is also the only path for + // a repository with no remote. + if let Err(error) = fetch_remote(&repo) { + tracing::warn!("Dispatch target could not fetch from the Git remote: {error:#}"); } - } else { - match fs::symlink_metadata(&upload_dir) { - Ok(metadata) => { - if metadata.file_type().is_symlink() || !metadata.is_dir() { - bail!("workspace upload path is not a private directory"); - } - fs::remove_dir_all(&upload_dir).with_context(|| { - format!("reset incomplete workspace upload {}", upload_dir.display()) - })?; + } + if !commit_exists(&repo, &request.base_commit)? { + return Ok(DispatchWorkspaceProvisionResponse { + pending: false, + provisioned: false, + needs_bundle: true, + workspace_path: None, + base_commit: request.base_commit, + branch: request.branch, + have_tips: repository_tips(&repo)?, + }); + } + + let workspace_path = + create_worktree(&repo, &worktree_path, &request.branch, &request.base_commit)?; + let mut record: ProvisionRecord = read_json(&record_path)?; + record.workspace_path = Some(workspace_path.clone()); + atomic_write_json(&record_path, &record)?; + Ok(DispatchWorkspaceProvisionResponse { + pending: false, + provisioned: true, + needs_bundle: false, + workspace_path: Some(workspace_path), + base_commit: request.base_commit, + branch: request.branch, + have_tips: Vec::new(), + }) +} + +pub(crate) fn bundle_begin( + request: DispatchWorkspaceBundleBeginRequest, +) -> Result { + let store = DispatchStore::open_default()?; + bundle_begin_in_store(&store, request) +} + +fn bundle_begin_in_store( + store: &DispatchStore, + request: DispatchWorkspaceBundleBeginRequest, +) -> Result { + if request.protocol_version != DISPATCH_PROTOCOL_VERSION { + bail!( + "unsupported dispatch protocolVersion {}; target requires {}", + request.protocol_version, + DISPATCH_PROTOCOL_VERSION + ); + } + super::store::validate_id("jobId", &request.job_id)?; + validate_digest(&request.sha256)?; + if request.size == 0 || request.size > MAX_BUNDLE_BYTES { + bail!("dispatch bundle size is outside the target limit"); + } + + let job_dir = store.workspace_upload_dir(&request.job_id)?; + let _lock = JobLock::exclusive(&store.workspace_operation_lock_path(&request.job_id)?)?; + create_private_dir(&job_dir)?; + let record_path = job_dir.join(BUNDLE_RECORD_FILE); + let bundle_path = job_dir.join(INCOMING_BUNDLE_FILE); + + match read_json::(&record_path) { + Ok(mut existing) => { + ensure_bundle_binding(&existing, &request)?; + if existing.state == BundleUploadState::Failed { + // The prior commit poll already received its diagnostic. A new + // begin is a new controller attempt and may resume the retained + // verified bytes instead of leaving the job permanently stuck. + existing.state = BundleUploadState::Uploading; + existing.worker_pid = None; + existing.last_error = None; + existing.updated_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json(&record_path, &existing)?; } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("inspect workspace upload {}", upload_dir.display())) + if existing.state == BundleUploadState::Committed { + return Ok(DispatchWorkspaceBundleBeginResponse { + accepted: true, + offset: existing.size, + committed: true, + }); } } - create_private_dir(&upload_dir)?; - let record = WorkspaceUploadRecord { - protocol_version: request.protocol_version, - job_id: request.job_id.clone(), - metadata: request.metadata.clone(), - state: WorkspaceUploadState::Uploading, - created_at: chrono::Utc::now().to_rfc3339(), - committed_at: None, - workspace_path: None, - last_error: None, - }; - atomic_write_json(&record_path, &record)?; + Err(_) => { + // A begin for different bytes replaces whatever partial upload was + // there: the digest is what binds an upload, and this one is new. + remove_file_if_present(&bundle_path); + atomic_write_json( + &record_path, + &BundleUploadRecord { + protocol_version: request.protocol_version, + job_id: request.job_id.clone(), + sha256: request.sha256.to_ascii_lowercase(), + size: request.size, + state: BundleUploadState::Uploading, + created_at: chrono::Utc::now().to_rfc3339(), + worker_pid: None, + last_error: None, + updated_at: chrono::Utc::now().to_rfc3339(), + }, + )?; + } } - let archive_path = upload_dir.join(UPLOAD_ARCHIVE_FILE); - if try_attach_cached_snapshot(store, &upload_dir, &archive_path, &request.metadata)? { - return Ok(DispatchWorkspaceBeginResponse { - accepted: true, - offset: request.metadata.archive_size, - upload_path: archive_path.to_string_lossy().to_string(), - committed: false, - workspace_path: None, - }); - } - let archive_metadata = fs::symlink_metadata(&archive_path); - let offset = match archive_metadata { + let offset = match fs::symlink_metadata(&bundle_path) { Ok(metadata) => { if metadata.file_type().is_symlink() || !metadata.is_file() { - bail!("workspace upload archive is not a regular file"); + bail!("dispatch bundle upload path is not a regular file"); } - set_private_file_permissions(&archive_path)?; - if metadata.len() > request.metadata.archive_size { - let file = OpenOptions::new() + set_private_file_permissions(&bundle_path)?; + if metadata.len() > request.size { + OpenOptions::new() .write(true) - .open(&archive_path) - .context("open oversized workspace upload archive")?; - file.set_len(0) - .context("reset oversized workspace upload archive")?; + .open(&bundle_path) + .context("open oversized dispatch bundle upload")? + .set_len(0) + .context("reset oversized dispatch bundle upload")?; 0 } else { metadata.len() } } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&archive_path) - .context("create workspace upload archive")?; - drop(file); - set_private_file_permissions(&archive_path)?; + drop( + OpenOptions::new() + .write(true) + .create_new(true) + .open(&bundle_path) + .context("create dispatch bundle upload")?, + ); + set_private_file_permissions(&bundle_path)?; 0 } - Err(error) => return Err(error).context("inspect workspace upload archive"), + Err(error) => return Err(error).context("inspect dispatch bundle upload"), }; - Ok(DispatchWorkspaceBeginResponse { + Ok(DispatchWorkspaceBundleBeginResponse { accepted: true, offset, - upload_path: archive_path.to_string_lossy().to_string(), committed: false, - workspace_path: None, }) } -pub(crate) fn chunk( - request: DispatchWorkspaceChunkRequest, -) -> Result { +pub(crate) fn bundle_chunk( + request: DispatchWorkspaceBundleChunkRequest, +) -> Result { if request.data_base64.len() > MAX_CHUNK_BASE64_BYTES { - bail!("workspace upload chunk exceeds the encoded safety limit"); + bail!("dispatch bundle chunk exceeds the encoded safety limit"); } let data = base64::engine::general_purpose::STANDARD .decode(request.data_base64.as_bytes()) - .context("decode workspace upload chunk")?; + .context("decode dispatch bundle chunk")?; if data.is_empty() || data.len() > MAX_CHUNK_BYTES { - bail!( - "workspace upload chunk must contain 1-{} bytes", - MAX_CHUNK_BYTES - ); + bail!("dispatch bundle chunk must contain 1-{MAX_CHUNK_BYTES} bytes"); } 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("workspace upload was not initialized")?; - ensure_upload_identity(&record, &request.job_id)?; - ensure_upload_not_failed(&record)?; - if record.state != WorkspaceUploadState::Uploading { - bail!("workspace upload is not accepting chunks"); - } - let archive_path = upload_dir.join(UPLOAD_ARCHIVE_FILE); + let job_dir = store.workspace_upload_dir(&request.job_id)?; + let _lock = JobLock::exclusive(&store.workspace_operation_lock_path(&request.job_id)?)?; + let record: BundleUploadRecord = read_json(&job_dir.join(BUNDLE_RECORD_FILE)) + .context("dispatch bundle upload was not initialized")?; + if record.job_id != request.job_id { + bail!("dispatch bundle upload identity mismatch"); + } + ensure_bundle_not_failed(&record)?; + if record.state != BundleUploadState::Uploading { + bail!("dispatch bundle upload is not accepting chunks"); + } + + let bundle_path = job_dir.join(INCOMING_BUNDLE_FILE); let mut file = OpenOptions::new() .read(true) .write(true) - .open(&archive_path) - .context("open workspace upload archive")?; - set_private_file_permissions(&archive_path)?; + .open(&bundle_path) + .context("open dispatch bundle upload")?; + set_private_file_permissions(&bundle_path)?; let current = file.metadata()?.len(); let chunk_end = request.offset.saturating_add(data.len() as u64); - if chunk_end > record.metadata.archive_size { - bail!("workspace upload chunk exceeds the declared archive size"); + if chunk_end > record.size { + bail!("dispatch bundle chunk exceeds the declared size"); } if request.offset < current { if chunk_end > current { - bail!("workspace upload chunk overlaps the retained archive tail"); + bail!("dispatch bundle chunk overlaps the retained tail"); } + // A retry that repeats bytes already on disk is accepted only when it + // repeats them exactly; anything else means two different bundles are + // racing for one job. file.seek(SeekFrom::Start(request.offset))?; let mut existing = vec![0_u8; data.len()]; file.read_exact(&mut existing)?; if existing != data { - bail!("workspace upload retry does not match retained bytes"); + bail!("dispatch bundle retry does not match retained bytes"); } - return Ok(DispatchWorkspaceChunkResponse { + return Ok(DispatchWorkspaceBundleChunkResponse { accepted: true, offset: current, }); } if request.offset != current { bail!( - "workspace upload offset mismatch: expected {}, received {}", - current, + "dispatch bundle offset mismatch: expected {current}, received {}", request.offset ); } file.seek(SeekFrom::End(0))?; file.write_all(&data)?; file.sync_data()?; - Ok(DispatchWorkspaceChunkResponse { + Ok(DispatchWorkspaceBundleChunkResponse { accepted: true, offset: chunk_end, }) } -pub(crate) fn commit( - request: DispatchWorkspaceCommitRequest, -) -> Result { +pub(crate) fn bundle_commit( + request: DispatchWorkspaceBundleCommitRequest, +) -> Result { 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 Some(_lock) = JobLock::try_exclusive(&lock_path)? else { - let record: WorkspaceUploadRecord = read_json(&upload_dir.join(UPLOAD_RECORD_FILE)) - .context("workspace upload was not initialized")?; - ensure_upload_identity(&record, &request.job_id)?; - ensure_upload_not_failed(&record)?; - return Ok(pending_commit_response(&record)); - }; - let record_path = upload_dir.join(UPLOAD_RECORD_FILE); - let mut record: WorkspaceUploadRecord = - read_json(&record_path).context("workspace upload was not initialized")?; - ensure_upload_identity(&record, &request.job_id)?; - ensure_upload_not_failed(&record)?; - if record.state == WorkspaceUploadState::Committed { - let workspace_path = - validate_committed_workspace(&upload_dir, record.workspace_path.as_deref())?; - return Ok(DispatchWorkspaceCommitResponse { + let job_dir = store.workspace_upload_dir(&request.job_id)?; + let _lock = JobLock::exclusive(&store.workspace_operation_lock_path(&request.job_id)?)?; + let record_path = job_dir.join(BUNDLE_RECORD_FILE); + let mut record: BundleUploadRecord = + read_json(&record_path).context("dispatch bundle upload was not initialized")?; + if record.job_id != request.job_id { + bail!("dispatch bundle upload identity mismatch"); + } + if record.state == BundleUploadState::Failed { + let diagnostic = record + .last_error + .clone() + .unwrap_or_else(|| "target did not retain a diagnostic".to_string()); + record.state = BundleUploadState::Uploading; + record.worker_pid = None; + record.last_error = None; + record.updated_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json(&record_path, &record)?; + bail!("dispatch bundle delivery failed: {diagnostic}"); + } + if record.state == BundleUploadState::Committed { + return Ok(DispatchWorkspaceBundleCommitResponse { committed: true, - workspace_path: Some(workspace_path), - metadata: record.metadata, + pending: false, }); } - - if managed_workspace_exists(&upload_dir)? { - // Extraction publishes this directory only after every digest and - // manifest check succeeds. Recover the narrow crash window between - // directory publication and record publication. - let workspace_path = mark_workspace_committed(&record_path, &upload_dir, &mut record)?; - return Ok(DispatchWorkspaceCommitResponse { - committed: true, - workspace_path: Some(workspace_path), - metadata: record.metadata, + if record.state == BundleUploadState::Committing + && record.worker_pid.is_some_and(|pid| { + workspace_worker_is_active( + pid, + "__workspace_bundle_commit_run", + &request.job_id, + &record.updated_at, + ) + }) + { + return Ok(DispatchWorkspaceBundleCommitResponse { + committed: false, + pending: true, }); } - let archive_path = upload_dir.join(UPLOAD_ARCHIVE_FILE); - validate_complete_archive(&archive_path, &record.metadata)?; - super::runner::spawn_workspace_materializer(&request.job_id)?; - Ok(pending_commit_response(&record)) + record.state = BundleUploadState::Committing; + record.worker_pid = None; + record.last_error = None; + record.updated_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json(&record_path, &record)?; + match super::runner::spawn_workspace_bundle_commit(&request.job_id) { + Ok(pid) => { + record.worker_pid = Some(pid); + record.updated_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json(&record_path, &record)?; + Ok(DispatchWorkspaceBundleCommitResponse { + committed: false, + pending: true, + }) + } + Err(error) => { + record.state = BundleUploadState::Failed; + record.last_error = Some(truncate_utf8(&format!("{error:#}"))); + record.updated_at = chrono::Utc::now().to_rfc3339(); + let _ = atomic_write_json(&record_path, &record); + Err(error) + } + } } -/// 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 { - 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(¤t) { - bail!("managed dispatch workspace is missing"); - } - let bundle_path = upload_dir.join(RESULT_BUNDLE_FILE); - let summary = create_workspace_result_bundle(¤t, &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 half of `workspace-bundle-commit`. +pub(crate) fn run_bundle_commit(job_id: String) -> Result<()> { + bundle_commit_in_store( + &DispatchStore::open_default()?, + DispatchWorkspaceBundleCommitRequest { job_id }, + ) + .map(|_| ()) } -/// Stream back a slice of the bundle `result` already produced. -/// -/// Read-only and bounded: it never rebuilds the bundle, so the digest the -/// controller verified stays the digest it receives. -pub(crate) fn result_chunk( - request: DispatchWorkspaceResultChunkRequest, -) -> Result { - if request.length == 0 || request.length > MAX_CHUNK_BYTES as u64 { - bail!("workspace result chunk length must be between 1 and {MAX_CHUNK_BYTES} bytes"); +fn bundle_commit_in_store( + store: &DispatchStore, + request: DispatchWorkspaceBundleCommitRequest, +) -> Result { + let job_dir = store.workspace_upload_dir(&request.job_id)?; + let _git_lock = JobLock::exclusive(&store.workspace_git_operation_lock_path(&request.job_id)?)?; + let record_path = job_dir.join(BUNDLE_RECORD_FILE); + let record: BundleUploadRecord = + read_json(&record_path).context("dispatch bundle upload was not initialized")?; + if record.job_id != request.job_id { + bail!("dispatch bundle upload identity mismatch"); } - let store = DispatchStore::open_default()?; - let upload_dir = store.workspace_upload_dir(&request.job_id)?; - let bundle_path = upload_dir.join(RESULT_BUNDLE_FILE); - let mut file = fs::File::open(&bundle_path) - .context("build the dispatch result bundle before reading it")?; - let size = file.metadata()?.len(); - if request.offset > size { - bail!("workspace result chunk offset is past the end of the bundle"); + ensure_bundle_not_failed(&record)?; + if record.state == BundleUploadState::Committed { + return Ok(DispatchWorkspaceBundleCommitResponse { + committed: true, + pending: false, + }); } - file.seek(SeekFrom::Start(request.offset))?; - let remaining = size - request.offset; - let take = request.length.min(remaining) as usize; - let mut buffer = vec![0_u8; take]; - file.read_exact(&mut buffer) - .context("read dispatch result bundle")?; - let next_offset = request.offset + take as u64; - Ok(DispatchWorkspaceResultChunkResponse { - offset: next_offset, - data_base64: base64::engine::general_purpose::STANDARD.encode(&buffer), - eof: next_offset >= size, - }) -} - -/// 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. -pub(crate) fn materialize(job_id: String) -> Result<()> { - let store = DispatchStore::open_default()?; - materialize_in_store(&store, &job_id) -} -fn materialize_in_store(store: &DispatchStore, job_id: &str) -> Result<()> { - let upload_dir = store.workspace_upload_dir(job_id)?; - let lock_path = workspace_upload_lock_path(store, job_id); - let _lock = JobLock::exclusive(&lock_path)?; - let record_path = upload_dir.join(UPLOAD_RECORD_FILE); - let mut record: WorkspaceUploadRecord = - read_json(&record_path).context("workspace upload was not initialized")?; - ensure_upload_identity(&record, job_id)?; - ensure_upload_not_failed(&record)?; - if record.state == WorkspaceUploadState::Committed { - validate_committed_workspace(&upload_dir, record.workspace_path.as_deref())?; - return Ok(()); - } - let current = upload_dir.join(CURRENT_WORKSPACE_DIR); - let result = (|| -> Result<()> { - if managed_workspace_exists(&upload_dir)? { - mark_workspace_committed(&record_path, &upload_dir, &mut record)?; - return Ok(()); + let provision: ProvisionRecord = read_json(&job_dir.join(PROVISION_RECORD_FILE)) + .context("dispatch bundle requires a provisioned job")?; + let _repo_lock = JobLock::exclusive(&store.repo_lock_path(&provision.repo_key)?)?; + let bundle_path = job_dir.join(INCOMING_BUNDLE_FILE); + let outcome = (|| -> Result<()> { + let metadata = fs::symlink_metadata(&bundle_path).context("inspect dispatch bundle")?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("dispatch bundle is not a regular file"); } - - remove_stale_staging_directories(&upload_dir)?; - 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())); - 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, ¤t).with_context(|| { - format!( - "publish dispatch workspace {} -> {}", - staging.display(), - current.display() - ) - })?; - sync_directory(&upload_dir)?; - if let Err(error) = persist_verified_snapshot_cache(store, &archive_path, &record.metadata) - { - tracing::warn!( - "Failed to retain verified dispatch workspace snapshot: digest={} error={error:#}", - record.metadata.archive_sha256 + if metadata.len() != record.size { + bail!( + "dispatch bundle is incomplete: expected {} bytes, received {}", + record.size, + metadata.len() ); } - mark_workspace_committed(&record_path, &upload_dir, &mut record)?; - remove_file_if_present(&archive_path); + if !sha256_file(&bundle_path)?.eq_ignore_ascii_case(&record.sha256) { + bail!("dispatch bundle SHA-256 mismatch"); + } + let repo = ensure_repository(store, &provision.repo_key, provision.remote_url.as_deref())?; + // `git bundle verify` checks the bundle's own integrity and that every + // prerequisite commit is already present, so a bundle that would leave + // a broken history is rejected before it touches the object store. + git(&repo, &["bundle", "verify", path_arg(&bundle_path)?]) + .context("verify dispatch bundle")?; + git( + &repo, + &[ + "fetch", + "--no-tags", + path_arg(&bundle_path)?, + &format!("+refs/heads/{0}:refs/heads/{0}", provision.branch), + ], + ) + .context("fetch dispatch bundle into the target repository")?; + if !commit_exists(&repo, &provision.base_commit)? { + bail!("dispatch bundle did not deliver the requested base commit"); + } Ok(()) })(); - if let Err(error) = result { - // Once `current` exists, a later commit can recover the narrow crash - // window between atomic publication and record publication. Before - // publication, persist a bounded diagnostic so controllers do not - // poll an irrecoverably bad archive until their transport timeout. - if !is_real_directory(¤t) { - record.state = WorkspaceUploadState::Failed; + + let _state_lock = JobLock::exclusive(&store.workspace_operation_lock_path(&request.job_id)?)?; + // Re-read after the long Git operation so a poller's atomic state update is + // never overwritten by a stale in-memory copy. + let mut record: BundleUploadRecord = read_json(&record_path)?; + match outcome { + Ok(()) => { + record.state = BundleUploadState::Committed; + record.worker_pid = None; + record.updated_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json(&record_path, &record)?; + // The objects live in the repository now; the transfer artifact is + // pure duplication. + remove_file_if_present(&bundle_path); + Ok(DispatchWorkspaceBundleCommitResponse { + committed: true, + pending: false, + }) + } + Err(error) => { + record.state = BundleUploadState::Failed; + record.worker_pid = None; record.last_error = Some(truncate_utf8(&format!("{error:#}"))); + record.updated_at = chrono::Utc::now().to_rfc3339(); let _ = atomic_write_json(&record_path, &record); + Err(error) } - return Err(error); - } - Ok(()) -} - -fn ensure_upload_identity(record: &WorkspaceUploadRecord, job_id: &str) -> Result<()> { - if record.job_id != job_id { - bail!("workspace upload identity mismatch"); } - Ok(()) } -fn ensure_begin_binding( - record: &WorkspaceUploadRecord, - request: &DispatchWorkspaceBeginRequest, -) -> Result<()> { - if record.protocol_version != request.protocol_version - || record.job_id != request.job_id - || record.metadata != request.metadata +/// Commit whatever the agent changed and package it as a bundle. +/// +/// Read-only with respect to the controller: it only ever adds commits on the +/// job's own branch, so a controller that never syncs leaves no trace here. +pub(crate) fn sync(request: DispatchWorkspaceSyncRequest) -> Result { + let store = DispatchStore::open_default()?; + super::store::validate_id("jobId", &request.job_id)?; + super::store::validate_id("operationId", &request.operation_id)?; + if request + .known_head + .as_deref() + .is_some_and(|head| validate_commit(head).is_err()) { - bail!("workspace upload job is already bound to different snapshot metadata"); + bail!("dispatch knownHead must be a full 40-character commit id"); } - Ok(()) -} + let job_dir = store.workspace_upload_dir(&request.job_id)?; + let _lock = JobLock::exclusive(&store.workspace_operation_lock_path(&request.job_id)?)?; + let provision: ProvisionRecord = read_json(&job_dir.join(PROVISION_RECORD_FILE)) + .context("this job did not receive a Git workspace")?; + let operation_path = job_dir.join(SYNC_OPERATION_FILE); + let mut operation = match read_optional_json::(&operation_path)? { + // Polls from one controller invocation must observe its durable result, + // especially a clean result whose head is identical to `knownHead`. + // Checking this before the generation boundary prevents every poll + // from reopening that completed no-op. + Some(existing) if existing.request.operation_id == request.operation_id => { + if existing.request != request { + bail!("dispatch operationId is already bound to a different sync request"); + } + existing + } + // Before the controller records a changed head, a retry uses a new + // operation id but the old known head. Return the retained bundle + // response instead of starting over so transfer/apply failures remain + // safely retryable. + Some(existing) + if existing.state == WorkspaceOperationState::Succeeded + && existing + .response + .as_ref() + .is_some_and(|response| response.changed) + && existing.request.known_head == request.known_head => + { + existing + } + // An acknowledged head is the generation boundary. A later click gets + // a new operation id and intentionally re-opens the operation so a + // still-running agent's newer commits can be discovered. + Some(existing) + if existing.state == WorkspaceOperationState::Succeeded + && request.known_head.as_deref() + == existing + .response + .as_ref() + .map(|response| response.head_commit.as_str()) => + { + SyncOperationRecord { + request: request.clone(), + state: WorkspaceOperationState::Pending, + worker_pid: None, + response: None, + last_error: None, + failure_reported: false, + updated_at: chrono::Utc::now().to_rfc3339(), + } + } + // A failed generation remains durable until its diagnostic has been + // returned. Older v3 journals did not carry operationId, so the first + // current request acts as that final poll before a retry can take over. + Some(existing) + if existing.state == WorkspaceOperationState::Failed + && existing.request.operation_id.is_empty() + && !existing.failure_reported => + { + existing + } + // Replacing an abandoned generation is safe only while holding this + // job's operation lock and after its detached worker is verifiably + // gone. Pending/Running without a live worker covers process crashes + // and the failure-consumption state written by early v3 builds. + Some(existing) if sync_operation_can_be_replaced(&existing, &request.job_id) => { + new_sync_operation(request.clone()) + } + Some(_) => bail!("dispatch sync is already bound to a different request"), + None => new_sync_operation(request.clone()), + }; -fn ensure_upload_not_failed(record: &WorkspaceUploadRecord) -> Result<()> { - if record.state == WorkspaceUploadState::Failed { - bail!( - "workspace materialization failed: {}", - record + match operation.state { + WorkspaceOperationState::Succeeded => { + return operation + .response + .context("dispatch sync operation has no response") + } + WorkspaceOperationState::Failed => { + let diagnostic = operation .last_error - .as_deref() - .unwrap_or("target did not retain a diagnostic") - ); + .clone() + .unwrap_or_else(|| "target retained no diagnostic".to_string()); + if !operation.failure_reported { + operation.failure_reported = true; + operation.updated_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json(&operation_path, &operation)?; + } + bail!("dispatch workspace sync failed: {diagnostic}"); + } + WorkspaceOperationState::Pending | WorkspaceOperationState::Running + if operation.worker_pid.is_some_and(|pid| { + workspace_worker_is_active( + pid, + "__workspace_sync_run", + &request.job_id, + &operation.updated_at, + ) + }) => + { + return Ok(pending_sync_response(&provision)); + } + WorkspaceOperationState::Pending | WorkspaceOperationState::Running => {} + } + + operation.state = WorkspaceOperationState::Pending; + operation.worker_pid = None; + operation.response = None; + operation.last_error = None; + operation.failure_reported = false; + operation.updated_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json(&operation_path, &operation)?; + match super::runner::spawn_workspace_sync(&request.job_id) { + Ok(pid) => { + operation.worker_pid = Some(pid); + operation.updated_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json(&operation_path, &operation)?; + Ok(pending_sync_response(&provision)) + } + Err(error) => { + operation.state = WorkspaceOperationState::Failed; + operation.last_error = Some(truncate_utf8(&format!("{error:#}"))); + // This synchronous failure is returned by the current call, so it + // does not need one more poll before a new operation can retry. + operation.failure_reported = true; + operation.updated_at = chrono::Utc::now().to_rfc3339(); + let _ = atomic_write_json(&operation_path, &operation); + Err(error) + } } - Ok(()) } -fn pending_commit_response(record: &WorkspaceUploadRecord) -> DispatchWorkspaceCommitResponse { - DispatchWorkspaceCommitResponse { - committed: false, - workspace_path: None, - metadata: record.metadata.clone(), +/// Detached half of `workspace-sync`. +pub(crate) fn run_sync(job_id: String) -> Result<()> { + let store = DispatchStore::open_default()?; + let job_dir = store.workspace_upload_dir(&job_id)?; + let operation_path = job_dir.join(SYNC_OPERATION_FILE); + let request = { + let _lock = JobLock::exclusive(&store.workspace_operation_lock_path(&job_id)?)?; + let mut operation: SyncOperationRecord = read_json(&operation_path) + .context("dispatch workspace sync operation was not initialized")?; + if operation.state == WorkspaceOperationState::Succeeded { + return Ok(()); + } + operation.state = WorkspaceOperationState::Running; + operation.worker_pid = Some(std::process::id()); + operation.updated_at = chrono::Utc::now().to_rfc3339(); + let request = operation.request.clone(); + atomic_write_json(&operation_path, &operation)?; + request + }; + + let outcome = sync_in_store(&store, request.clone()); + let _lock = JobLock::exclusive(&store.workspace_operation_lock_path(&job_id)?)?; + let mut operation: SyncOperationRecord = read_json(&operation_path)?; + // A replacement is allowed only after the previous worker is gone, but + // retain a generation fence as defense in depth against stale/corrupt PIDs. + if operation.request != request { + return Ok(()); + } + operation.worker_pid = None; + operation.updated_at = chrono::Utc::now().to_rfc3339(); + match outcome { + Ok(response) => { + operation.state = WorkspaceOperationState::Succeeded; + operation.response = Some(response); + operation.last_error = None; + operation.failure_reported = false; + atomic_write_json(&operation_path, &operation) + } + Err(error) => { + operation.state = WorkspaceOperationState::Failed; + operation.last_error = Some(truncate_utf8(&format!("{error:#}"))); + operation.failure_reported = false; + atomic_write_json(&operation_path, &operation)?; + Err(error) + } } } -fn is_real_directory(path: &Path) -> bool { - fs::symlink_metadata(path) - .ok() - .is_some_and(|metadata| !metadata.file_type().is_symlink() && metadata.is_dir()) +fn new_sync_operation(request: DispatchWorkspaceSyncRequest) -> SyncOperationRecord { + SyncOperationRecord { + request, + state: WorkspaceOperationState::Pending, + worker_pid: None, + response: None, + last_error: None, + failure_reported: false, + updated_at: chrono::Utc::now().to_rfc3339(), + } } -fn managed_workspace_exists(upload_dir: &Path) -> Result { - let current = upload_dir.join(CURRENT_WORKSPACE_DIR); - match fs::symlink_metadata(¤t) { - Ok(metadata) if !metadata.file_type().is_symlink() && metadata.is_dir() => Ok(true), - Ok(_) => bail!("managed dispatch workspace path is not a real directory"), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(error).context("inspect managed dispatch workspace"), +fn sync_operation_can_be_replaced(operation: &SyncOperationRecord, job_id: &str) -> bool { + let worker_is_active = operation.worker_pid.is_some_and(|pid| { + workspace_worker_is_active(pid, "__workspace_sync_run", job_id, &operation.updated_at) + }); + if worker_is_active { + return false; + } + match operation.state { + WorkspaceOperationState::Pending | WorkspaceOperationState::Running => true, + WorkspaceOperationState::Failed => operation.failure_reported, + WorkspaceOperationState::Succeeded => false, } } -fn mark_workspace_committed( - record_path: &Path, - upload_dir: &Path, - record: &mut WorkspaceUploadRecord, -) -> Result { - let workspace_path = validate_committed_workspace(upload_dir, None)?; - record.state = WorkspaceUploadState::Committed; - record.committed_at = Some(chrono::Utc::now().to_rfc3339()); - record.workspace_path = Some(workspace_path.clone()); - atomic_write_json(record_path, record)?; - Ok(workspace_path) +fn pending_sync_response(provision: &ProvisionRecord) -> DispatchWorkspaceSyncResponse { + DispatchWorkspaceSyncResponse { + pending: true, + changed: false, + branch: provision.branch.clone(), + base_commit: provision.base_commit.clone(), + head_commit: provision.base_commit.clone(), + commit_count: 0, + changes: Vec::new(), + truncated_changes: false, + bundle_path: None, + bundle_sha256: None, + bundle_size: 0, + } } -fn validate_complete_archive( - archive_path: &Path, - metadata: &WorkspaceSnapshotMetadata, -) -> Result<()> { - let archive = - fs::symlink_metadata(archive_path).context("inspect complete workspace upload archive")?; - if archive.file_type().is_symlink() || !archive.is_file() { - bail!("workspace upload archive is not a regular file"); +fn sync_in_store( + store: &DispatchStore, + request: DispatchWorkspaceSyncRequest, +) -> Result { + let job_dir = store.workspace_upload_dir(&request.job_id)?; + let _git_lock = JobLock::exclusive(&store.workspace_git_operation_lock_path(&request.job_id)?)?; + let provision: ProvisionRecord = read_json(&job_dir.join(PROVISION_RECORD_FILE)) + .context("this job did not receive a Git workspace")?; + let _repo_lock = JobLock::exclusive(&store.repo_lock_path(&provision.repo_key)?)?; + let worktree = store.worktree_dir(&request.job_id)?; + if !is_real_directory(&worktree) { + bail!("the dispatch worktree is missing"); } - if archive.len() != metadata.archive_size { + + let current_branch = git(&worktree, &["symbolic-ref", "--quiet", "--short", "HEAD"]) + .context("verify the dispatch worktree branch before syncing")? + .trim() + .to_string(); + if current_branch != provision.branch { bail!( - "workspace upload is incomplete: expected {} bytes, received {}", - metadata.archive_size, - archive.len() + "dispatch worktree is on branch '{}' instead of its managed branch '{}'", + current_branch, + provision.branch ); } - Ok(()) -} - -/// Reuse a verified snapshot uploaded by an earlier job. -/// -/// The cache owns one immutable, content-addressed archive. Each job gets a -/// hard link only while its detached materializer is reading the archive; the -/// link is removed after `current/` is published. This keeps writable job -/// workspaces isolated while avoiding another network transfer and another -/// compressed archive on the target. -fn try_attach_cached_snapshot( - store: &DispatchStore, - upload_dir: &Path, - archive_path: &Path, - expected: &WorkspaceSnapshotMetadata, -) -> Result { - let cache_root = store.workspace_snapshot_cache_root(); - let digest = expected.archive_sha256.to_ascii_lowercase(); - let cache_dir = cache_root.join(&digest); - let _lock = JobLock::exclusive(&workspace_snapshot_cache_lock_path(store, &digest))?; - let Some(mut record) = read_valid_snapshot_cache(&cache_dir, expected)? else { - discard_snapshot_cache_entry(&cache_dir)?; - return Ok(false); - }; - match fs::symlink_metadata(archive_path) { - Ok(metadata) => { - if metadata.file_type().is_symlink() || !metadata.is_file() { - bail!("workspace upload archive is not a regular file"); - } - fs::remove_file(archive_path) - .with_context(|| format!("replace workspace upload {}", archive_path.display()))?; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(error).context("inspect workspace upload archive"), - } - fs::hard_link(cache_dir.join(CACHE_ARCHIVE_FILE), archive_path).with_context(|| { - format!( - "attach cached workspace snapshot {} -> {}", - cache_dir.display(), - upload_dir.display() + git(&worktree, &["add", "-A"]).context("stage dispatch worktree changes")?; + if !git_succeeds(&worktree, &["diff", "--cached", "--quiet"])? { + let message = request + .message + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_SYNC_COMMIT_MESSAGE); + git( + &worktree, + &[ + "-c", + "commit.gpgsign=false", + "-c", + "user.name=BitFun Dispatch", + "-c", + "user.email=dispatch@bitfun.local", + "commit", + "--no-verify", + "-m", + message, + ], ) - })?; - set_private_file_permissions(archive_path)?; - - record.last_used_at = chrono::Utc::now().to_rfc3339(); - atomic_write_json( - &cache_dir.join(WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE), - &record, - )?; - Ok(true) -} - -/// Publish an archive only after extraction verified its archive digest, -/// manifest digest, paths, entry types, and size limits. -fn persist_verified_snapshot_cache( - store: &DispatchStore, - archive_path: &Path, - expected: &WorkspaceSnapshotMetadata, -) -> Result<()> { - let cache_root = store.workspace_snapshot_cache_root(); - let digest = expected.archive_sha256.to_ascii_lowercase(); - let cache_dir = cache_root.join(&digest); - let _lock = JobLock::exclusive(&workspace_snapshot_cache_lock_path(store, &digest))?; - - if let Some(mut record) = read_valid_snapshot_cache(&cache_dir, expected)? { - record.last_used_at = chrono::Utc::now().to_rfc3339(); - atomic_write_json( - &cache_dir.join(WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE), - &record, - )?; - return Ok(()); + .context("commit dispatch worktree changes")?; } - discard_snapshot_cache_entry(&cache_dir)?; - if !sha256_file(archive_path)?.eq_ignore_ascii_case(&expected.archive_sha256) { - bail!("verified workspace snapshot changed before it entered the cache"); + let head_commit = git(&worktree, &["rev-parse", "HEAD"])?.trim().to_string(); + if !git_succeeds( + &worktree, + &[ + "merge-base", + "--is-ancestor", + &provision.base_commit, + &head_commit, + ], + )? { + bail!("dispatch branch no longer descends from its immutable base commit"); } - let staging = cache_root.join(format!( - ".staging-{}-{}", - digest, - uuid::Uuid::new_v4().as_simple() - )); - create_private_dir(&staging)?; - let cached_archive = staging.join(CACHE_ARCHIVE_FILE); - let result = (|| -> Result<()> { - if fs::hard_link(archive_path, &cached_archive).is_err() { - fs::copy(archive_path, &cached_archive).with_context(|| { - format!( - "copy verified workspace snapshot into cache {}", - cached_archive.display() - ) - })?; + let sync_base = request + .known_head + .as_deref() + .unwrap_or(&provision.base_commit) + .to_string(); + if !commit_exists(&worktree, &sync_base)? + || !git_succeeds( + &worktree, + &["merge-base", "--is-ancestor", &sync_base, &head_commit], + )? + { + bail!("dispatch knownHead is not an ancestor of the managed branch"); + } + if head_commit == sync_base { + return Ok(DispatchWorkspaceSyncResponse { + pending: false, + changed: false, + branch: provision.branch, + base_commit: sync_base, + head_commit, + commit_count: 0, + changes: Vec::new(), + truncated_changes: false, + bundle_path: None, + bundle_sha256: None, + bundle_size: 0, + }); + } + + let range = format!("{sync_base}..{head_commit}"); + let commit_count = git(&worktree, &["rev-list", "--count", &range])? + .trim() + .parse::() + .unwrap_or(0); + let (changes, truncated_changes) = collect_changes(&worktree, &sync_base, &head_commit)?; + + let bundle_path = job_dir.join(RESULT_BUNDLE_FILE); + remove_file_if_present(&bundle_path); + // The wanted side must name a ref, not a commit id: a bundle carries refs, + // and `git bundle` refuses to create one that would contain none. + let bundle_range = format!("{sync_base}..{}", provision.branch); + git( + &worktree, + &["bundle", "create", path_arg(&bundle_path)?, &bundle_range], + ) + .context("package dispatch result bundle")?; + set_private_file_permissions(&bundle_path)?; + let bundle_size = fs::symlink_metadata(&bundle_path) + .context("inspect dispatch result bundle")? + .len(); + let bundle_sha256 = sha256_file(&bundle_path)?; + + Ok(DispatchWorkspaceSyncResponse { + pending: false, + changed: true, + branch: provision.branch, + base_commit: sync_base, + head_commit, + commit_count, + changes, + truncated_changes, + bundle_path: Some(bundle_path.to_string_lossy().to_string()), + bundle_sha256: Some(bundle_sha256), + bundle_size, + }) +} + +/// Stream back a slice of the bundle `sync` already produced. +/// +/// Never rebuilds the bundle, so the digest the controller verified stays the +/// digest it receives. +pub(crate) fn sync_chunk( + request: DispatchWorkspaceSyncChunkRequest, +) -> Result { + if request.length == 0 || request.length > MAX_CHUNK_BYTES as u64 { + bail!("dispatch sync chunk length must be between 1 and {MAX_CHUNK_BYTES} bytes"); + } + let store = DispatchStore::open_default()?; + let job_dir = store.workspace_upload_dir(&request.job_id)?; + let bundle_path = job_dir.join(RESULT_BUNDLE_FILE); + let mut file = + fs::File::open(&bundle_path).context("run the dispatch sync before reading its bundle")?; + let size = file.metadata()?.len(); + if request.offset > size { + bail!("dispatch sync chunk offset is past the end of the bundle"); + } + file.seek(SeekFrom::Start(request.offset))?; + let remaining = size - request.offset; + let take = request.length.min(remaining) as usize; + let mut buffer = vec![0_u8; take]; + file.read_exact(&mut buffer) + .context("read dispatch result bundle")?; + let next_offset = request.offset + take as u64; + Ok(DispatchWorkspaceSyncChunkResponse { + offset: next_offset, + data_base64: base64::engine::general_purpose::STANDARD.encode(&buffer), + eof: next_offset >= size, + }) +} + +fn collect_changes( + worktree: &Path, + base_commit: &str, + head_commit: &str, +) -> Result<(Vec, bool)> { + let raw = git( + worktree, + &[ + "diff", + "--name-status", + "--no-renames", + base_commit, + head_commit, + ], + )?; + let mut changes = Vec::new(); + let mut truncated = false; + for line in raw.lines() { + let mut parts = line.splitn(2, '\t'); + let (Some(status), Some(path)) = (parts.next(), parts.next()) else { + continue; + }; + if changes.len() >= MAX_REPORTED_CHANGES { + truncated = true; + break; } - set_private_file_permissions(&cached_archive)?; - let now = chrono::Utc::now().to_rfc3339(); - atomic_write_json( - &staging.join(WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE), - &WorkspaceSnapshotCacheRecord { - metadata: expected.clone(), - created_at: now.clone(), - last_used_at: now, - }, - )?; - sync_directory(&staging)?; - fs::rename(&staging, &cache_dir).with_context(|| { - format!( - "publish dispatch workspace cache {} -> {}", - staging.display(), - cache_dir.display() - ) - })?; - sync_directory(&cache_root)?; - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_dir_all(&staging); + changes.push(DispatchWorkspaceSyncedChange { + status: status.trim().to_string(), + path: path.trim().to_string(), + }); } - result + Ok((changes, truncated)) } -fn read_valid_snapshot_cache( - cache_dir: &Path, - expected: &WorkspaceSnapshotMetadata, -) -> Result> { - let directory = match fs::symlink_metadata(cache_dir) { - Ok(metadata) => metadata, +fn existing_worktree( + worktree_path: &Path, + branch: &str, + base_commit: &str, +) -> Result> { + match fs::symlink_metadata(worktree_path) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error).context("inspect dispatch workspace cache"), - }; - if directory.file_type().is_symlink() || !directory.is_dir() { - return Ok(None); + Err(error) => return Err(error).context("inspect the dispatch worktree path"), + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + bail!( + "dispatch worktree path exists but is not a safe directory: {}", + worktree_path.display() + ); + } + Ok(_) => {} } - let record = match read_json::( - &cache_dir.join(WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE), - ) { - Ok(record) => record, - Err(_) => return Ok(None), - }; - if record.metadata != *expected { + // An existing directory only counts as this job's worktree when Git agrees. + // A non-Git directory under this managed root is a partial `worktree add` + // left by a crash. Quarantine it so the idempotent retry can rebuild. + if !git_succeeds(worktree_path, &["rev-parse", "--git-dir"])? { + quarantine_partial_directory(worktree_path, "worktree")?; return Ok(None); } - let archive_path = cache_dir.join(CACHE_ARCHIVE_FILE); - let archive = match fs::symlink_metadata(&archive_path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error).context("inspect cached workspace snapshot"), + if !commit_exists(worktree_path, base_commit)? { + bail!("dispatch worktree exists without the requested base commit"); + } + let current_branch = git( + worktree_path, + &["symbolic-ref", "--quiet", "--short", "HEAD"], + ) + .context("inspect the existing dispatch worktree branch")? + .trim() + .to_string(); + if current_branch != branch { + bail!( + "dispatch worktree is on branch '{current_branch}' instead of its managed branch '{branch}'" + ); + } + let head = git(worktree_path, &["rev-parse", "HEAD"])?; + if !git_succeeds( + worktree_path, + &["merge-base", "--is-ancestor", base_commit, head.trim()], + )? { + bail!("existing dispatch worktree does not descend from its requested base commit"); + } + Ok(Some(canonical_utf8(worktree_path)?)) +} + +fn ensure_repository( + store: &DispatchStore, + repo_key: &str, + remote_url: Option<&str>, +) -> Result { + let repo_root = store.repo_dir(repo_key)?; + create_private_dir(&repo_root)?; + let repo = repo_root.join("git"); + let initialize = match fs::symlink_metadata(&repo) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => return Err(error).context("inspect the dispatch repository cache"), + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + bail!( + "dispatch repository cache exists but is not a safe directory: {}", + repo.display() + ); + } + Ok(_) => { + let valid = git(&repo, &["rev-parse", "--is-bare-repository"]) + .map(|value| value.trim() == "true") + .unwrap_or(false); + if !valid { + quarantine_partial_directory(&repo, "repository")?; + } + !valid + } }; - if archive.file_type().is_symlink() - || !archive.is_file() - || archive.len() != expected.archive_size - { - return Ok(None); + if initialize { + let parent = repo + .parent() + .ok_or_else(|| anyhow::anyhow!("dispatch repository path has no parent"))?; + git(parent, &["init", "--bare", "--quiet", "git"]) + .context("initialize the dispatch target repository")?; } - if !sha256_file(&archive_path)?.eq_ignore_ascii_case(&expected.archive_sha256) { - return Ok(None); + if let Some(url) = remote_url { + set_origin(&repo, url)?; } - Ok(Some(record)) + let now = chrono::Utc::now().to_rfc3339(); + let record_path = repo_root.join(super::store::REPO_CACHE_RECORD_FILE); + let created_at = read_json::(&record_path) + .map(|record| record.created_at) + .unwrap_or_else(|_| now.clone()); + atomic_write_json( + &record_path, + &RepoCacheRecord { + remote_url: remote_url.map(ToOwned::to_owned), + created_at, + last_used_at: now, + }, + )?; + sync_directory(&repo_root)?; + Ok(repo) } -fn discard_snapshot_cache_entry(cache_dir: &Path) -> Result<()> { - match fs::symlink_metadata(cache_dir) { - Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => { - fs::remove_file(cache_dir).with_context(|| { - format!( - "remove invalid dispatch workspace cache {}", - cache_dir.display() - ) - }) +fn set_origin(repo: &Path, url: &str) -> Result<()> { + if git_succeeds(repo, &["remote", "get-url", "origin"])? { + git(repo, &["remote", "set-url", "origin", url])?; + } else { + git(repo, &["remote", "add", "origin", url])?; + } + Ok(()) +} + +fn fetch_remote(repo: &Path) -> Result<()> { + git( + repo, + &[ + "fetch", + "--no-tags", + "--prune", + "origin", + "+refs/heads/*:refs/remotes/origin/*", + ], + ) + .map(|_| ()) +} + +fn repository_tips(repo: &Path) -> Result> { + let raw = git(repo, &["rev-list", "--max-count=64", "--all"])?; + Ok(raw + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect()) +} + +fn create_worktree( + repo: &Path, + worktree_path: &Path, + branch: &str, + base_commit: &str, +) -> Result { + if let Some(parent) = worktree_path.parent() { + create_private_dir(parent)?; + } + // Registered-but-missing worktrees survive a crashed job and would make + // `worktree add` refuse the same path forever. + let _ = git(repo, &["worktree", "prune"]); + let branch_ref = format!("refs/heads/{branch}"); + if git_succeeds(repo, &["show-ref", "--verify", "--quiet", &branch_ref])? { + // A missing checkout may still leave the job branch with valuable + // commits. Reattach it when it descends from the immutable baseline; + // never reset it back to the base and make that work unreachable. + if !git_succeeds( + repo, + &["merge-base", "--is-ancestor", base_commit, &branch_ref], + )? { + bail!("existing dispatch branch does not descend from the requested base commit"); } - Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(cache_dir).with_context(|| { - format!( - "remove invalid dispatch workspace cache {}", - cache_dir.display() - ) - }), - Ok(_) => bail!("dispatch workspace cache entry is an unsupported file type"), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error).context("inspect invalid dispatch workspace cache"), + } else { + git(repo, &["update-ref", &branch_ref, base_commit]) + .context("point the dispatch branch at the requested base commit")?; + } + git(repo, &["worktree", "add", path_arg(worktree_path)?, branch]) + .context("create the dispatch worktree")?; + canonical_utf8(worktree_path) +} + +fn commit_exists(repo: &Path, commit: &str) -> Result { + git_succeeds(repo, &["cat-file", "-e", &format!("{commit}^{{commit}}")]) +} + +fn git_command(dir: &Path) -> Command { + let mut command = Command::new("git"); + command + .current_dir(dir) + // A detached dispatch worker has nobody to answer a credential or + // host-key prompt, so every one of them must fail fast instead of + // blocking the job until its transport times out. + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", "") + .env("SSH_ASKPASS", "") + .env("GCM_INTERACTIVE", "never") + .stdin(Stdio::null()); + command +} + +fn git(dir: &Path, args: &[&str]) -> Result { + let output = git_command(dir) + .args(args) + .output() + .with_context(|| format!("run git {}", args.join(" ")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "git {} failed: {}", + args.join(" "), + truncate_utf8(stderr.trim()) + ); } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn git_succeeds(dir: &Path, args: &[&str]) -> Result { + let status = git_command(dir) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .with_context(|| format!("run git {}", args.join(" ")))?; + Ok(status.success()) +} + +fn path_arg(path: &Path) -> Result<&str> { + path.to_str() + .ok_or_else(|| anyhow::anyhow!("dispatch path is not valid UTF-8: {}", path.display())) } -fn workspace_snapshot_cache_lock_path(store: &DispatchStore, digest: &str) -> PathBuf { - store - .workspace_snapshot_cache_root() - .join(format!(".{digest}.lock")) +fn canonical_utf8(path: &Path) -> Result { + path.canonicalize() + .with_context(|| format!("resolve dispatch path {}", path.display()))? + .to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow::anyhow!("dispatch path is not valid UTF-8")) } -fn remove_stale_staging_directories(upload_dir: &Path) -> Result<()> { - for entry in fs::read_dir(upload_dir) - .with_context(|| format!("read workspace upload directory {}", upload_dir.display()))? +fn is_real_directory(path: &Path) -> bool { + fs::symlink_metadata(path) + .ok() + .is_some_and(|metadata| !metadata.file_type().is_symlink() && metadata.is_dir()) +} + +fn quarantine_partial_directory(path: &Path, label: &str) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("managed dispatch {label} path has no parent"))?; + let tombstone = parent.join(format!( + ".partial-{label}-{}", + uuid::Uuid::new_v4().as_simple() + )); + fs::rename(path, &tombstone) + .with_context(|| format!("quarantine partial dispatch {label} {}", path.display()))?; + fs::remove_dir_all(&tombstone) + .with_context(|| format!("remove partial dispatch {label} {}", tombstone.display())) +} + +fn workspace_worker_is_active(pid: u32, action: &str, job_id: &str, updated_at: &str) -> bool { + super::runner::workspace_operation_process_alive(pid, action, job_id) + || chrono::DateTime::parse_from_rfc3339(updated_at) + .ok() + .map(|updated| { + chrono::Utc::now() + .signed_duration_since(updated) + .num_seconds() + }) + .is_some_and(|age| (0..OPERATION_START_GRACE_SECONDS).contains(&age)) +} + +fn ensure_provision_binding( + record: &ProvisionRecord, + request: &DispatchWorkspaceProvisionRequest, +) -> Result<()> { + if record.job_id != request.job_id + || record.repo_key != request.repo_key + || record.base_commit != request.base_commit + || record.branch != request.branch { - let entry = entry?; - let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { - continue; - }; - if !name.starts_with(".staging-") { - continue; - } - let path = entry.path(); - let metadata = fs::symlink_metadata(&path)?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - bail!("workspace upload contains an unsafe staging path"); + bail!("dispatch job is already bound to a different Git baseline"); + } + Ok(()) +} + +fn read_optional_json Deserialize<'de>>(path: &Path) -> Result> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("dispatch operation record is not a regular file"); + } + read_json(path).map(Some) } - fs::remove_dir_all(&path) - .with_context(|| format!("remove stale workspace staging {}", path.display()))?; + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())), + } +} + +fn ensure_bundle_binding( + record: &BundleUploadRecord, + request: &DispatchWorkspaceBundleBeginRequest, +) -> Result<()> { + if record.job_id != request.job_id + || !record.sha256.eq_ignore_ascii_case(&request.sha256) + || record.size != request.size + { + bail!("dispatch job is already bound to a different bundle"); + } + Ok(()) +} + +fn ensure_bundle_not_failed(record: &BundleUploadRecord) -> Result<()> { + if record.state == BundleUploadState::Failed { + bail!( + "dispatch bundle delivery failed: {}", + record + .last_error + .as_deref() + .unwrap_or("target did not retain a diagnostic") + ); } Ok(()) } -fn validate_begin(request: &DispatchWorkspaceBeginRequest) -> Result<()> { +fn validate_provision(request: &DispatchWorkspaceProvisionRequest) -> Result<()> { if request.protocol_version != DISPATCH_PROTOCOL_VERSION { bail!( "unsupported dispatch protocolVersion {}; target requires {}", @@ -780,61 +1479,83 @@ fn validate_begin(request: &DispatchWorkspaceBeginRequest) -> Result<()> { ); } super::store::validate_id("jobId", &request.job_id)?; - let metadata = &request.metadata; - if metadata.format_version != WORKSPACE_SNAPSHOT_FORMAT_VERSION { - bail!("unsupported workspace snapshot format"); + validate_repo_key(&request.repo_key)?; + validate_commit(&request.base_commit)?; + validate_branch(&request.branch)?; + if let Some(url) = request.remote_url.as_deref() { + validate_remote_url(url)?; } - if metadata.archive_size == 0 || metadata.archive_size > MAX_SNAPSHOT_ARCHIVE_BYTES { - bail!("workspace snapshot archive size is outside the target limit"); - } - if metadata.file_count > MAX_SNAPSHOT_FILES - || metadata.directory_count > MAX_SNAPSHOT_DIRECTORIES - || metadata.uncompressed_bytes > MAX_SNAPSHOT_UNCOMPRESSED_BYTES + Ok(()) +} + +/// The repo key names a directory, so it is restricted to a hex digest the +/// controller derives rather than anything user-controlled. +fn validate_repo_key(repo_key: &str) -> Result<()> { + if repo_key.len() < 8 + || repo_key.len() > 64 + || !repo_key.bytes().all(|byte| byte.is_ascii_hexdigit()) { - bail!("workspace snapshot summary exceeds target safety limits"); + bail!("dispatch repoKey must be an 8-64 character hex digest"); } - for digest in [&metadata.archive_sha256, &metadata.manifest_sha256] { - if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { - bail!("workspace snapshot metadata contains an invalid SHA-256 digest"); - } + Ok(()) +} + +fn validate_commit(commit: &str) -> Result<()> { + if commit.len() != 40 || !commit.bytes().all(|byte| byte.is_ascii_hexdigit()) { + bail!("dispatch baseCommit must be a full 40-character commit id"); } Ok(()) } -fn validate_committed_workspace( - upload_dir: &Path, - recorded_workspace_path: Option<&str>, -) -> Result { - let current = upload_dir.join(CURRENT_WORKSPACE_DIR); - let metadata = - fs::symlink_metadata(¤t).context("inspect committed dispatch workspace")?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - bail!("committed dispatch workspace is not a real directory"); - } - let canonical = current - .canonicalize() - .context("resolve committed dispatch workspace")?; - if recorded_workspace_path.is_some_and(|recorded| Path::new(recorded) != canonical) { - bail!("committed dispatch workspace path no longer matches its durable record"); - } - canonical - .to_str() - .map(ToOwned::to_owned) - .ok_or_else(|| anyhow::anyhow!("committed dispatch workspace path is not valid UTF-8")) +fn validate_digest(digest: &str) -> Result<()> { + if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + bail!("dispatch bundle digest must be a SHA-256 hex string"); + } + Ok(()) } -fn workspace_upload_lock_path(store: &DispatchStore, job_id: &str) -> PathBuf { - store - .root() - .join("workspaces") - .join(format!(".{job_id}.upload.lock")) +/// Reject anything Git itself would not accept as a branch, plus leading +/// dashes, which Git would read as an option rather than a ref. +fn validate_branch(branch: &str) -> Result<()> { + if branch.is_empty() || branch.len() > 255 || branch.starts_with('-') { + bail!("dispatch branch name is outside the accepted range"); + } + if !branch + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.')) + { + bail!("dispatch branch name contains unsupported characters"); + } + if branch.contains("..") || branch.ends_with('/') || branch.ends_with(".lock") { + bail!("dispatch branch name is not a valid Git ref"); + } + Ok(()) +} + +/// A remote URL becomes a `git remote` argument, so it must not look like an +/// option, and `ext::` would let a URL execute an arbitrary command. +fn validate_remote_url(url: &str) -> Result<()> { + if url.is_empty() || url.len() > 2048 { + bail!("dispatch remoteUrl is outside the accepted length"); + } + if url.starts_with('-') { + bail!("dispatch remoteUrl must not start with a dash"); + } + if url.bytes().any(|byte| byte.is_ascii_control()) { + bail!("dispatch remoteUrl must not contain control characters"); + } + if url.to_ascii_lowercase().starts_with("ext::") { + bail!("dispatch remoteUrl must not use the ext transport"); + } + Ok(()) } fn truncate_utf8(value: &str) -> String { - if value.len() <= MAX_MATERIALIZATION_ERROR_BYTES { + const MAX_ERROR_BYTES: usize = 16 * 1024; + if value.len() <= MAX_ERROR_BYTES { return value.to_string(); } - let mut end = MAX_MATERIALIZATION_ERROR_BYTES; + let mut end = MAX_ERROR_BYTES; while !value.is_char_boundary(end) { end -= 1; } @@ -844,220 +1565,677 @@ fn truncate_utf8(value: &str) -> String { #[cfg(test)] mod tests { use super::*; - use bitfun_services_core::dispatch_workspace::create_exact_workspace_snapshot; + + fn init_source_repository(path: &Path) -> String { + fs::create_dir_all(path).expect("source directory"); + git(path, &["init", "--quiet", "--initial-branch=main"]).expect("init"); + git(path, &["config", "user.email", "dispatch@example.com"]).expect("email"); + git(path, &["config", "user.name", "Dispatch Test"]).expect("name"); + fs::write(path.join("file.txt"), b"base").expect("seed file"); + git(path, &["add", "-A"]).expect("stage"); + git(path, &["commit", "--quiet", "-m", "base"]).expect("commit"); + git(path, &["rev-parse", "HEAD"]) + .expect("head") + .trim() + .to_string() + } + + fn bundle_everything(source: &Path, bundle: &Path) { + git( + source, + &["bundle", "create", path_arg(bundle).expect("path"), "main"], + ) + .expect("bundle"); + } #[test] - fn validation_rejects_unbounded_or_malformed_uploads() { - let request = DispatchWorkspaceBeginRequest { - protocol_version: DISPATCH_PROTOCOL_VERSION, - job_id: "job-1".to_string(), - metadata: WorkspaceSnapshotMetadata { - format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, - archive_size: 1, - archive_sha256: "x".repeat(64), - manifest_sha256: "0".repeat(64), - file_count: 0, - directory_count: 0, - uncompressed_bytes: 0, + fn provision_asks_for_a_bundle_when_the_commit_is_unreachable() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let response = provision_in_store( + &store, + DispatchWorkspaceProvisionRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + repo_key: "abcdef0123456789".to_string(), + remote_url: None, + base_commit: "0".repeat(40), + branch: "bitfun/dispatch/job-1".to_string(), }, - }; - assert!(validate_begin(&request).is_err()); + ) + .expect("provision"); + + assert!(!response.provisioned); + assert!(response.needs_bundle); + assert!(response.workspace_path.is_none()); } #[test] - fn snapshot_fixture_metadata_is_accepted() { + fn a_delivered_bundle_provisions_a_worktree_at_the_requested_commit() { let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); let source = temp.path().join("source"); - fs::create_dir_all(&source).expect("source"); - fs::write(source.join("file.txt"), b"hello").expect("file"); - let metadata = - create_exact_workspace_snapshot(&source, &temp.path().join("snapshot.tar.gz")) - .expect("snapshot"); - validate_begin(&DispatchWorkspaceBeginRequest { + let base_commit = init_source_repository(&source); + let bundle = temp.path().join("base.bundle"); + bundle_everything(&source, &bundle); + + let request = DispatchWorkspaceProvisionRequest { protocol_version: DISPATCH_PROTOCOL_VERSION, job_id: "job-1".to_string(), - metadata, - }) - .expect("valid metadata"); + repo_key: "abcdef0123456789".to_string(), + remote_url: None, + base_commit: base_commit.clone(), + branch: "main".to_string(), + }; + assert!( + provision_in_store(&store, request.clone()) + .expect("first provision") + .needs_bundle + ); + + let job_dir = store.workspace_upload_dir("job-1").expect("job dir"); + let size = fs::symlink_metadata(&bundle) + .expect("bundle metadata") + .len(); + bundle_begin_in_store( + &store, + DispatchWorkspaceBundleBeginRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + sha256: sha256_file(&bundle).expect("digest"), + size, + }, + ) + .expect("bundle begin"); + fs::copy(&bundle, job_dir.join(INCOMING_BUNDLE_FILE)).expect("stage bundle"); + assert!( + bundle_commit_in_store( + &store, + DispatchWorkspaceBundleCommitRequest { + job_id: "job-1".to_string() + }, + ) + .expect("bundle commit") + .committed + ); + + let response = provision_in_store(&store, request).expect("second provision"); + assert!(response.provisioned); + assert!(!response.needs_bundle); + let workspace = response.workspace_path.expect("workspace path"); + assert_eq!( + fs::read(Path::new(&workspace).join("file.txt")).expect("checked out file"), + b"base" + ); } #[test] - fn materializer_verifies_and_atomically_publishes_the_uploaded_snapshot() { + fn reprovisioning_a_missing_checkout_never_resets_the_job_branch() { let temp = tempfile::tempdir().expect("tempdir"); let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); let source = temp.path().join("source"); - fs::create_dir_all(&source).expect("source"); - fs::write(source.join("file.txt"), b"materialized").expect("source file"); - let source_archive = temp.path().join("source.tar.gz"); - let metadata = create_exact_workspace_snapshot(&source, &source_archive).expect("snapshot"); - let upload_dir = store.workspace_upload_dir("job-1").expect("upload path"); - create_private_dir(&upload_dir).expect("upload directory"); - fs::copy(&source_archive, upload_dir.join(UPLOAD_ARCHIVE_FILE)).expect("stage archive"); - atomic_write_json( - &upload_dir.join(UPLOAD_RECORD_FILE), - &WorkspaceUploadRecord { + let base_commit = init_source_repository(&source); + provision_from_bundle(&store, &source, &base_commit); + + let worktree = store.worktree_dir("job-1").expect("worktree"); + fs::write(worktree.join("agent.txt"), b"valuable work").expect("edit"); + git(&worktree, &["add", "-A"]).expect("stage"); + git( + &worktree, + &[ + "-c", + "user.name=Dispatch Test", + "-c", + "user.email=dispatch@example.com", + "commit", + "--quiet", + "-m", + "agent work", + ], + ) + .expect("commit"); + let advanced = git(&worktree, &["rev-parse", "HEAD"]) + .expect("head") + .trim() + .to_string(); + let repo = store + .repo_dir("abcdef0123456789") + .expect("repo") + .join("git"); + git( + &repo, + &[ + "worktree", + "remove", + "--force", + path_arg(&worktree).unwrap(), + ], + ) + .expect("remove checkout only"); + + let response = provision_in_store( + &store, + DispatchWorkspaceProvisionRequest { protocol_version: DISPATCH_PROTOCOL_VERSION, job_id: "job-1".to_string(), - metadata: metadata.clone(), - state: WorkspaceUploadState::Uploading, - created_at: chrono::Utc::now().to_rfc3339(), - committed_at: None, - workspace_path: None, - last_error: None, + repo_key: "abcdef0123456789".to_string(), + remote_url: None, + base_commit, + branch: "main".to_string(), }, ) - .expect("upload record"); - - materialize_in_store(&store, "job-1").expect("materialize"); - + .expect("reprovision"); + let restored = PathBuf::from(response.workspace_path.expect("restored path")); + assert_eq!( + git(&restored, &["rev-parse", "HEAD"]).unwrap().trim(), + advanced + ); assert_eq!( - fs::read(upload_dir.join(CURRENT_WORKSPACE_DIR).join("file.txt")) - .expect("materialized file"), - b"materialized" + fs::read(restored.join("agent.txt")).unwrap(), + b"valuable work" ); - assert!(!upload_dir.join(UPLOAD_ARCHIVE_FILE).exists()); - let record: WorkspaceUploadRecord = - read_json(&upload_dir.join(UPLOAD_RECORD_FILE)).expect("committed record"); - assert_eq!(record.state, WorkspaceUploadState::Committed); - assert_eq!(record.metadata, metadata); - assert!(record.workspace_path.is_some()); } #[test] - fn identical_jobs_reuse_one_verified_target_archive_but_keep_writes_isolated() { + fn sync_reports_no_change_for_an_untouched_worktree() { let temp = tempfile::tempdir().expect("tempdir"); let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); let source = temp.path().join("source"); - fs::create_dir_all(&source).expect("source"); - fs::write(source.join("file.txt"), b"shared input").expect("source file"); - let source_archive = temp.path().join("source.tar.gz"); - let metadata = create_exact_workspace_snapshot(&source, &source_archive).expect("snapshot"); + let base_commit = init_source_repository(&source); + provision_from_bundle(&store, &source, &base_commit); - let first = begin_in_store( + let response = sync_in_store( &store, - DispatchWorkspaceBeginRequest { - protocol_version: DISPATCH_PROTOCOL_VERSION, + DispatchWorkspaceSyncRequest { job_id: "job-1".to_string(), - metadata: metadata.clone(), + operation_id: "sync-untouched".to_string(), + message: None, + known_head: None, }, ) - .expect("begin first upload"); - assert_eq!(first.offset, 0); - fs::copy(&source_archive, &first.upload_path).expect("upload first snapshot"); - materialize_in_store(&store, "job-1").expect("materialize first job"); - - let cache_dir = store - .workspace_snapshot_cache_root() - .join(&metadata.archive_sha256); - assert!(cache_dir.join(CACHE_ARCHIVE_FILE).is_file()); - assert_eq!( - fs::read_dir(store.workspace_snapshot_cache_root()) - .expect("read cache") - .filter_map(Result::ok) - .filter(|entry| entry.path().is_dir()) - .count(), - 1 - ); + .expect("sync"); + + assert!(!response.changed); + assert_eq!(response.commit_count, 0); + assert!(response.bundle_path.is_none()); + } + + #[test] + fn sync_commits_agent_edits_and_bundles_only_the_new_history() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let source = temp.path().join("source"); + let base_commit = init_source_repository(&source); + provision_from_bundle(&store, &source, &base_commit); - let second = begin_in_store( + let worktree = store.worktree_dir("job-1").expect("worktree"); + git(&worktree, &["config", "user.email", "dispatch@example.com"]).expect("email"); + git(&worktree, &["config", "user.name", "Dispatch Test"]).expect("name"); + fs::write(worktree.join("file.txt"), b"changed by the agent").expect("edit"); + fs::write(worktree.join("added.txt"), b"new").expect("add"); + + let response = sync_in_store( &store, - DispatchWorkspaceBeginRequest { - protocol_version: DISPATCH_PROTOCOL_VERSION, - job_id: "job-2".to_string(), - metadata: metadata.clone(), + DispatchWorkspaceSyncRequest { + job_id: "job-1".to_string(), + operation_id: "sync-agent-edits".to_string(), + message: Some("agent work".to_string()), + known_head: None, }, ) - .expect("begin cached upload"); + .expect("sync"); + + assert!(response.changed); + assert_eq!(response.commit_count, 1); + assert_eq!(response.base_commit, base_commit); + assert_ne!(response.head_commit, base_commit); + let mut paths = response + .changes + .iter() + .map(|change| change.path.clone()) + .collect::>(); + paths.sort(); + assert_eq!(paths, vec!["added.txt".to_string(), "file.txt".to_string()]); + + // The bundle carries only what the controller is missing, so applying it + // is a fast-forward rather than a re-delivery of the whole repository. + let bundle = PathBuf::from(response.bundle_path.expect("bundle path")); + assert!(bundle.is_file()); + let prerequisites = git( + &worktree, + &["bundle", "list-heads", path_arg(&bundle).unwrap()], + ) + .expect("list heads"); + assert!(prerequisites.contains("refs/heads/main")); + } + + #[test] + fn sync_uses_known_head_as_the_incremental_generation_boundary() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let source = temp.path().join("source"); + let base_commit = init_source_repository(&source); + provision_from_bundle(&store, &source, &base_commit); + let worktree = store.worktree_dir("job-1").expect("worktree"); + + fs::write(worktree.join("first.txt"), b"first checkpoint").expect("first edit"); + let first = sync_in_store( + &store, + DispatchWorkspaceSyncRequest { + job_id: "job-1".to_string(), + operation_id: "sync-first".to_string(), + message: None, + known_head: None, + }, + ) + .expect("first sync"); + assert!(first.changed); + + let unchanged = sync_in_store( + &store, + DispatchWorkspaceSyncRequest { + job_id: "job-1".to_string(), + operation_id: "sync-clean".to_string(), + message: None, + known_head: Some(first.head_commit.clone()), + }, + ) + .expect("clean incremental sync"); + assert!(!unchanged.changed); + assert_eq!(unchanged.base_commit, first.head_commit); + + fs::write(worktree.join("second.txt"), b"second checkpoint").expect("second edit"); + let second = sync_in_store( + &store, + DispatchWorkspaceSyncRequest { + job_id: "job-1".to_string(), + operation_id: "sync-second".to_string(), + message: None, + known_head: Some(first.head_commit.clone()), + }, + ) + .expect("second sync"); + assert!(second.changed); + assert_eq!(second.base_commit, first.head_commit); + assert_eq!(second.commit_count, 1); assert_eq!( - second.offset, metadata.archive_size, - "a cache hit must tell the controller that no bytes remain" + second + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(), + vec!["second.txt"] ); + } + + #[test] + fn completed_clean_sync_poll_returns_the_durable_result() { + const CHILD_ENV: &str = "BITFUN_DISPATCH_CLEAN_SYNC_POLL_CHILD"; + if let Some(bitfun_home) = std::env::var_os(CHILD_ENV) { + let store = DispatchStore::open_default().expect("open isolated default store"); + let source = PathBuf::from(bitfun_home).join("source"); + let base_commit = init_source_repository(&source); + provision_from_bundle(&store, &source, &base_commit); + + // Model the second user-requested sync: the controller has already + // acknowledged the target head, and this invocation finds no newer + // work. Its later polls must return this clean response instead of + // opening another detached operation forever. + let request = DispatchWorkspaceSyncRequest { + job_id: "job-1".to_string(), + operation_id: "sync-clean-poll".to_string(), + message: None, + known_head: Some(base_commit), + }; + let response = sync_in_store(&store, request.clone()).expect("clean sync"); + assert!(!response.changed); - fs::write( - store + let operation_path = store .workspace_upload_dir("job-1") - .expect("first workspace") - .join(CURRENT_WORKSPACE_DIR) - .join("file.txt"), - b"job one changed", - ) - .expect("modify first job"); - materialize_in_store(&store, "job-2").expect("materialize cached job"); - assert_eq!( - fs::read( - store - .workspace_upload_dir("job-2") - .expect("second workspace") - .join(CURRENT_WORKSPACE_DIR) - .join("file.txt") + .expect("workspace path") + .join(SYNC_OPERATION_FILE); + atomic_write_json( + &operation_path, + &SyncOperationRecord { + request: request.clone(), + state: WorkspaceOperationState::Succeeded, + worker_pid: None, + response: Some(response.clone()), + last_error: None, + failure_reported: false, + updated_at: chrono::Utc::now().to_rfc3339(), + }, ) - .expect("read second job"), - b"shared input", - "cache reuse must not share the writable job workspace" + .expect("seed completed operation"); + + let polled = sync(request.clone()).expect("poll completed operation"); + assert_eq!(polled, response); + assert!(!polled.pending); + let retained: SyncOperationRecord = + read_json(&operation_path).expect("read retained operation"); + assert_eq!(retained.state, WorkspaceOperationState::Succeeded); + assert_eq!(retained.request.operation_id, "sync-clean-poll"); + + let mut mismatched = request; + mismatched.message = Some("different request".to_string()); + let error = sync(mismatched).expect_err("one operation id binds one request"); + assert!(error.to_string().contains("operationId is already bound")); + 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::workspace::tests::completed_clean_sync_poll_returns_the_durable_result", + "--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 clean-sync poll 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!(cache_dir.join(CACHE_ARCHIVE_FILE).is_file()); - assert!(!store - .workspace_upload_dir("job-2") - .expect("second workspace") - .join(UPLOAD_ARCHIVE_FILE) - .exists()); } #[test] - fn materialization_failure_is_persisted_for_commit_pollers() { + fn reported_sync_failure_allows_a_new_operation_to_take_over() { + const CHILD_ENV: &str = "BITFUN_DISPATCH_FAILED_SYNC_RETRY_CHILD"; + if let Some(bitfun_home) = std::env::var_os(CHILD_ENV) { + let store = DispatchStore::open_default().expect("open isolated default store"); + let source = PathBuf::from(bitfun_home).join("source"); + let base_commit = init_source_repository(&source); + provision_from_bundle(&store, &source, &base_commit); + let operation_path = store + .workspace_upload_dir("job-1") + .expect("workspace path") + .join(SYNC_OPERATION_FILE); + let failed_request = DispatchWorkspaceSyncRequest { + job_id: "job-1".to_string(), + operation_id: "sync-failed-generation".to_string(), + message: None, + known_head: Some(base_commit.clone()), + }; + atomic_write_json( + &operation_path, + &SyncOperationRecord { + request: failed_request.clone(), + state: WorkspaceOperationState::Failed, + worker_pid: None, + response: None, + last_error: Some("transient Git lock".to_string()), + failure_reported: false, + updated_at: chrono::Utc::now().to_rfc3339(), + }, + ) + .expect("seed failed operation"); + + let error = sync(failed_request).expect_err("the failed generation must be reported"); + assert!(error.to_string().contains("transient Git lock")); + let mut retained: SyncOperationRecord = + read_json(&operation_path).expect("read reported failure"); + assert_eq!(retained.state, WorkspaceOperationState::Failed); + assert!(retained.failure_reported); + assert_eq!(retained.last_error.as_deref(), Some("transient Git lock")); + + let retry = DispatchWorkspaceSyncRequest { + job_id: "job-1".to_string(), + operation_id: "sync-retry-generation".to_string(), + message: None, + known_head: Some(base_commit), + }; + retained.worker_pid = Some(std::process::id()); + retained.updated_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json(&operation_path, &retained).expect("seed active worker marker"); + let error = + sync(retry.clone()).expect_err("an active generation must retain ownership"); + assert!(error + .to_string() + .contains("already bound to a different request")); + + retained.worker_pid = None; + atomic_write_json(&operation_path, &retained).expect("retire failed worker marker"); + let response = sync(retry.clone()).expect("retry with a new operation id"); + assert!(response.pending); + let replacement: SyncOperationRecord = + read_json(&operation_path).expect("read replacement operation"); + assert_eq!(replacement.request, retry); + assert!(matches!( + replacement.state, + WorkspaceOperationState::Pending | WorkspaceOperationState::Running + )); + assert!(!replacement.failure_reported); + 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::workspace::tests::reported_sync_failure_allows_a_new_operation_to_take_over", + "--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 failed-sync retry test"); + assert!( + output.status.success(), + "isolated child failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn legacy_sync_failure_without_operation_id_is_reported_then_retryable() { + const CHILD_ENV: &str = "BITFUN_DISPATCH_LEGACY_SYNC_RETRY_CHILD"; + if let Some(bitfun_home) = std::env::var_os(CHILD_ENV) { + let store = DispatchStore::open_default().expect("open isolated default store"); + let source = PathBuf::from(bitfun_home).join("source"); + let base_commit = init_source_repository(&source); + provision_from_bundle(&store, &source, &base_commit); + let operation_path = store + .workspace_upload_dir("job-1") + .expect("workspace path") + .join(SYNC_OPERATION_FILE); + // Early protocol-v3 development builds wrote neither operationId + // nor failureReported. Keep that exact JSON shape readable. + atomic_write_json( + &operation_path, + &serde_json::json!({ + "request": { + "jobId": "job-1", + "message": null, + "knownHead": base_commit, + }, + "state": "failed", + "workerPid": null, + "response": null, + "lastError": "legacy sync failure", + "updatedAt": chrono::Utc::now().to_rfc3339(), + }), + ) + .expect("seed legacy operation"); + + let first_retry = DispatchWorkspaceSyncRequest { + job_id: "job-1".to_string(), + operation_id: "sync-after-upgrade-1".to_string(), + message: None, + known_head: Some(base_commit.clone()), + }; + let error = sync(first_retry).expect_err("legacy failure must be surfaced once"); + assert!(error.to_string().contains("legacy sync failure")); + let reported: SyncOperationRecord = + read_json(&operation_path).expect("read upgraded legacy operation"); + assert!(reported.request.operation_id.is_empty()); + assert_eq!(reported.state, WorkspaceOperationState::Failed); + assert!(reported.failure_reported); + + let second_retry = DispatchWorkspaceSyncRequest { + job_id: "job-1".to_string(), + operation_id: "sync-after-upgrade-2".to_string(), + message: None, + known_head: Some(base_commit), + }; + let response = sync(second_retry.clone()).expect("replace legacy generation"); + assert!(response.pending); + let replacement: SyncOperationRecord = + read_json(&operation_path).expect("read replacement operation"); + assert_eq!(replacement.request, second_retry); + assert!(!replacement.failure_reported); + 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::workspace::tests::legacy_sync_failure_without_operation_id_is_reported_then_retryable", + "--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 legacy-sync retry test"); + assert!( + output.status.success(), + "isolated child failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn sync_fails_loudly_when_the_agent_switches_away_from_the_managed_branch() { let temp = tempfile::tempdir().expect("tempdir"); let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); let source = temp.path().join("source"); - fs::create_dir_all(&source).expect("source"); - fs::write(source.join("file.txt"), b"original").expect("source file"); - let source_archive = temp.path().join("source.tar.gz"); - let metadata = create_exact_workspace_snapshot(&source, &source_archive).expect("snapshot"); - let upload_dir = store.workspace_upload_dir("job-1").expect("upload path"); - create_private_dir(&upload_dir).expect("upload directory"); - let staged_archive = upload_dir.join(UPLOAD_ARCHIVE_FILE); - fs::copy(&source_archive, &staged_archive).expect("stage archive"); - let mut bytes = fs::read(&staged_archive).expect("archive"); - bytes[0] ^= 1; - fs::write(&staged_archive, bytes).expect("tamper archive"); - atomic_write_json( - &upload_dir.join(UPLOAD_RECORD_FILE), - &WorkspaceUploadRecord { - protocol_version: DISPATCH_PROTOCOL_VERSION, + let base_commit = init_source_repository(&source); + provision_from_bundle(&store, &source, &base_commit); + let worktree = store.worktree_dir("job-1").expect("worktree"); + git(&worktree, &["switch", "--quiet", "-c", "agent/other"]).expect("switch branch"); + + let error = sync_in_store( + &store, + DispatchWorkspaceSyncRequest { job_id: "job-1".to_string(), - metadata, - state: WorkspaceUploadState::Uploading, - created_at: chrono::Utc::now().to_rfc3339(), - committed_at: None, - workspace_path: None, - last_error: None, + operation_id: "sync-wrong-branch".to_string(), + message: None, + known_head: None, }, ) - .expect("upload record"); + .expect_err("a different branch must not be bundled under the managed ref"); + assert!(error.to_string().contains("instead of its managed branch")); + } - materialize_in_store(&store, "job-1").expect_err("tampering must fail"); + #[test] + fn provision_repairs_a_partial_bare_repository_left_by_a_crash() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let repo = store + .repo_dir("abcdef0123456789") + .expect("repo root") + .join("git"); + fs::create_dir_all(&repo).expect("partial repository directory"); - let record: WorkspaceUploadRecord = - read_json(&upload_dir.join(UPLOAD_RECORD_FILE)).expect("failed record"); - assert_eq!(record.state, WorkspaceUploadState::Failed); - assert!(record - .last_error - .as_deref() - .is_some_and(|message| message.contains("SHA-256 mismatch"))); - assert!(ensure_upload_not_failed(&record).is_err()); + let response = provision_in_store( + &store, + DispatchWorkspaceProvisionRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + repo_key: "abcdef0123456789".to_string(), + remote_url: None, + base_commit: "0".repeat(40), + branch: "bitfun/dispatch/job-1".to_string(), + }, + ) + .expect("partial repository should be rebuilt"); + + assert!(response.needs_bundle); + assert_eq!( + git(&repo, &["rev-parse", "--is-bare-repository"]) + .expect("inspect repaired repository") + .trim(), + "true" + ); } - #[cfg(unix)] #[test] - fn committed_workspace_validation_rejects_a_replaced_symlink() { - use std::os::unix::fs::symlink; - + fn polling_and_long_git_operations_use_distinct_locks() { let temp = tempfile::tempdir().expect("tempdir"); - let upload = temp.path().join("upload"); - let outside = temp.path().join("outside"); - fs::create_dir_all(&upload).expect("upload"); - fs::create_dir_all(&outside).expect("outside"); - symlink(&outside, upload.join(CURRENT_WORKSPACE_DIR)).expect("replace current"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + assert_ne!( + store.workspace_operation_lock_path("job-1").unwrap(), + store.workspace_git_operation_lock_path("job-1").unwrap() + ); + } - assert!(validate_committed_workspace(&upload, None).is_err()); + fn provision_from_bundle(store: &DispatchStore, source: &Path, base_commit: &str) { + let bundle = source.parent().expect("parent").join("base.bundle"); + bundle_everything(source, &bundle); + let request = DispatchWorkspaceProvisionRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + repo_key: "abcdef0123456789".to_string(), + remote_url: None, + base_commit: base_commit.to_string(), + branch: "main".to_string(), + }; + provision_in_store(store, request.clone()).expect("first provision"); + let job_dir = store.workspace_upload_dir("job-1").expect("job dir"); + bundle_begin_in_store( + store, + DispatchWorkspaceBundleBeginRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + sha256: sha256_file(&bundle).expect("digest"), + size: fs::symlink_metadata(&bundle).expect("metadata").len(), + }, + ) + .expect("bundle begin"); + fs::copy(&bundle, job_dir.join(INCOMING_BUNDLE_FILE)).expect("stage bundle"); + bundle_commit_in_store( + store, + DispatchWorkspaceBundleCommitRequest { + job_id: "job-1".to_string(), + }, + ) + .expect("bundle commit"); + provision_in_store(store, request).expect("second provision"); + } + + #[test] + fn hostile_provisioning_inputs_are_rejected_before_git_runs() { + assert!(validate_repo_key("../escape").is_err()); + assert!(validate_repo_key("zz").is_err()); + assert!(validate_commit("HEAD").is_err()); + assert!(validate_branch("--upload-pack=touch").is_err()); + assert!(validate_branch("feature/..\\/etc").is_err()); + assert!(validate_remote_url("ext::sh -c whoami").is_err()); + assert!(validate_remote_url("--upload-pack=touch").is_err()); + assert!(validate_remote_url("https://example.com/acme/app.git").is_ok()); } } diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index b34420a4d1..d0e23fbdc0 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -598,18 +598,30 @@ pub(crate) enum DispatchAction { Answer, /// Append a steering message to a queued or running job Append, - #[command(name = "__workspace_begin", hide = true)] - WorkspaceBegin, - #[command(name = "__workspace_chunk", hide = true)] - WorkspaceChunk, - #[command(name = "__workspace_commit", hide = true)] - WorkspaceCommit, - #[command(name = "__workspace_result", hide = true)] - WorkspaceResult, - #[command(name = "__workspace_result_chunk", hide = true)] - WorkspaceResultChunk, - #[command(name = "__workspace_materialize", hide = true)] - WorkspaceMaterialize { + #[command(name = "__workspace_provision", hide = true)] + WorkspaceProvision, + #[command(name = "__workspace_bundle_begin", hide = true)] + WorkspaceBundleBegin, + #[command(name = "__workspace_bundle_chunk", hide = true)] + WorkspaceBundleChunk, + #[command(name = "__workspace_bundle_commit", hide = true)] + WorkspaceBundleCommit, + #[command(name = "__workspace_sync", hide = true)] + WorkspaceSync, + #[command(name = "__workspace_sync_chunk", hide = true)] + WorkspaceSyncChunk, + #[command(name = "__workspace_provision_run", hide = true)] + WorkspaceProvisionRun { + #[arg(long)] + job: String, + }, + #[command(name = "__workspace_bundle_commit_run", hide = true)] + WorkspaceBundleCommitRun { + #[arg(long)] + job: String, + }, + #[command(name = "__workspace_sync_run", hide = true)] + WorkspaceSyncRun { #[arg(long)] job: String, }, @@ -1835,21 +1847,15 @@ mod dispatch_command_tests { }) if job == "job-1" )); assert!(is_dispatch_command(&worker.command)); - let materializer = Cli::try_parse_from([ - "bitfun", - "dispatch", - "__workspace_materialize", - "--job", - "job-1", - ]) - .expect("parse internal workspace materializer"); + let provision = Cli::try_parse_from(["bitfun", "dispatch", "__workspace_provision"]) + .expect("parse internal workspace provision"); assert!(matches!( - materializer.command, + provision.command, Some(Commands::Dispatch { - action: DispatchAction::WorkspaceMaterialize { ref job } - }) if job == "job-1" + action: DispatchAction::WorkspaceProvision + }) )); - assert!(is_dispatch_command(&materializer.command)); + assert!(is_dispatch_command(&provision.command)); let unrelated = Cli::try_parse_from(["bitfun", "config", "show"]).expect("parse config"); assert!(!is_dispatch_command(&unrelated.command)); diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 998ed174fc..69396397e5 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -75,11 +75,13 @@ 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_sync_result", "dispatch_cancel", "dispatch_list_jobs", "dispatch_answer", @@ -134,11 +136,13 @@ mod tests { "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_sync_result", "dispatch_cancel", "dispatch_list_jobs", "dispatch_answer", diff --git a/src/apps/cli/src/peer_host/dispatch.rs b/src/apps/cli/src/peer_host/dispatch.rs index c483f8abd4..329555b154 100644 --- a/src/apps/cli/src/peer_host/dispatch.rs +++ b/src/apps/cli/src/peer_host/dispatch.rs @@ -128,11 +128,12 @@ fn dispatch_target_verb(command: &str) -> Option<&'static str> { "dispatch_target_list" => Some("list"), "dispatch_target_answer" => Some("answer"), "dispatch_target_append" => Some("append"), - "dispatch_target_workspace_begin" => Some("workspace-begin"), - "dispatch_target_workspace_chunk" => Some("workspace-chunk"), - "dispatch_target_workspace_commit" => Some("workspace-commit"), - "dispatch_target_workspace_result" => Some("workspace-result"), - "dispatch_target_workspace_result_chunk" => Some("workspace-result-chunk"), + "dispatch_target_workspace_provision" => Some("workspace-provision"), + "dispatch_target_workspace_bundle_begin" => Some("workspace-bundle-begin"), + "dispatch_target_workspace_bundle_chunk" => Some("workspace-bundle-chunk"), + "dispatch_target_workspace_bundle_commit" => Some("workspace-bundle-commit"), + "dispatch_target_workspace_sync" => Some("workspace-sync"), + "dispatch_target_workspace_sync_chunk" => Some("workspace-sync-chunk"), _ => None, } } diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 5dfbb2f914..61de0020e6 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -52,8 +52,14 @@ 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::WorkspaceMaterialize { job } => { - return crate::dispatch::run_workspace_materializer(job) + DispatchAction::WorkspaceProvisionRun { job } => { + return crate::dispatch::run_workspace_provision(job) + } + DispatchAction::WorkspaceBundleCommitRun { job } => { + return crate::dispatch::run_workspace_bundle_commit(job) + } + DispatchAction::WorkspaceSyncRun { job } => { + return crate::dispatch::run_workspace_sync(job) } DispatchAction::Probe => "probe", DispatchAction::Submit => "submit", @@ -62,11 +68,12 @@ pub(crate) async fn handle_dispatch_action(action: DispatchAction) -> Result<()> DispatchAction::List => "list", DispatchAction::Answer => "answer", DispatchAction::Append => "append", - DispatchAction::WorkspaceBegin => "workspace-begin", - DispatchAction::WorkspaceChunk => "workspace-chunk", - DispatchAction::WorkspaceCommit => "workspace-commit", - DispatchAction::WorkspaceResult => "workspace-result", - DispatchAction::WorkspaceResultChunk => "workspace-result-chunk", + DispatchAction::WorkspaceProvision => "workspace-provision", + DispatchAction::WorkspaceBundleBegin => "workspace-bundle-begin", + DispatchAction::WorkspaceBundleChunk => "workspace-bundle-chunk", + DispatchAction::WorkspaceBundleCommit => "workspace-bundle-commit", + DispatchAction::WorkspaceSync => "workspace-sync", + DispatchAction::WorkspaceSyncChunk => "workspace-sync-chunk", }; let result = async { use std::io::{IsTerminal, Read}; diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index a911bbb251..819fac723a 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1323,6 +1323,9 @@ pub async fn create_session( source_workspace_path: Some(source_workspace_path.clone()), base_ref, copy_local_changes, + // A user-created worktree is claimed by the sessions bound to + // it, which already block automatic removal. + claimed_by: None, }) .await .map_err(|error| serde_json::to_string(&error).unwrap_or_else(|_| error.to_string()))?; diff --git a/src/apps/desktop/src/api/dispatch_api.rs b/src/apps/desktop/src/api/dispatch_api.rs index 2043ae9b2c..b466173b13 100644 --- a/src/apps/desktop/src/api/dispatch_api.rs +++ b/src/apps/desktop/src/api/dispatch_api.rs @@ -13,18 +13,17 @@ 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, cancel_device_dispatch, cancel_dispatch, cancel_dispatch_cli_install, + 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, pull_device_dispatch_result, pull_dispatch_result, - start_dispatch_cli_install, start_dispatch_cli_source_build, submit_device_dispatch, - submit_dispatch, sync_dispatch_model_config, DeviceDispatchRpc, DispatchAnswerRequest, - DispatchAppendRequest, DispatchApplyResultRequest, DispatchConnectionRequest, - DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, - DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, - DispatchSaveTranscriptRequest, DispatchStatusRequest, DispatchSubmitRequest, DispatchTarget, + probe_dispatch_target, start_dispatch_cli_install, start_dispatch_cli_source_build, + submit_device_dispatch, submit_dispatch, sync_device_dispatch_result, + sync_dispatch_model_config, sync_dispatch_result, DeviceDispatchRpc, DispatchAnswerRequest, + DispatchAppendRequest, DispatchConnectionRequest, DispatchInstallPollRequest, + DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, + DispatchListTargetsRequest, DispatchProbeTargetRequest, DispatchSaveTranscriptRequest, + DispatchStatusRequest, DispatchSubmitRequest, DispatchSyncResultRequest, DispatchTarget, DispatchTargetOption, DispatchTargetRequest, DispatchTranscriptRequest, OutboundDispatchStore, - WorkspaceResultApplyOutcome, }; use bitfun_core::service::remote_ssh::dispatch_ssh::{ DispatchInstallPoll, DispatchInstallStart, DispatchSshProbe, @@ -181,7 +180,6 @@ pub async fn dispatch_list_targets( device_id: Some(device.device_id), display_name: device.device_name, description: None, - default_workspace: None, online: Some(device.online), }) })); @@ -360,19 +358,19 @@ pub async fn dispatch_status( .map_err(|error| error.to_string()) } -/// Download what a finished snapshot job changed on its target. +/// Bring a job's work back into the controller's baseline worktree. /// -/// Fetch and report only — the caller shows the diff and the user decides -/// whether any of it reaches their workspace. +/// One operation on purpose: the target commits and bundles, then this +/// controller fast-forwards its baseline onto the result. There is no separate +/// apply step because there is nothing to reconcile — both sides branched from +/// the same commit. #[tauri::command] -pub async fn dispatch_pull_result( +pub async fn dispatch_sync_result( state: State<'_, AppState>, path_manager: State<'_, Arc>, - request: DispatchJobRequest, + request: DispatchSyncResultRequest, ) -> Result { let store = OutboundDispatchStore::new(path_manager.as_ref()); - // Both transports stage the bundle and its summary identically, so the - // apply step below is transport-blind. if matches!( store .get(&request.job_id) @@ -381,7 +379,7 @@ pub async fn dispatch_pull_result( .map(|record| record.target), Some(DispatchTarget::Device { .. }) ) { - return pull_device_dispatch_result(&AccountDeviceDispatchRpc, &store, request) + return sync_device_dispatch_result(&AccountDeviceDispatchRpc, &store, request) .await .map_err(|error| error.to_string()); } @@ -389,22 +387,7 @@ pub async fn dispatch_pull_result( .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>, - request: DispatchApplyResultRequest, -) -> Result { - let store = OutboundDispatchStore::new(path_manager.as_ref()); - apply_dispatch_result(&store, request) + sync_dispatch_result(&manager, &store, request) .await .map_err(|error| error.to_string()) } diff --git a/src/apps/desktop/src/api/dispatch_host.rs b/src/apps/desktop/src/api/dispatch_host.rs index 206e7850a4..1c7d654502 100644 --- a/src/apps/desktop/src/api/dispatch_host.rs +++ b/src/apps/desktop/src/api/dispatch_host.rs @@ -39,11 +39,12 @@ fn target_cli_verb(command: &str) -> Option<&'static str> { "dispatch_target_list" => Some("list"), "dispatch_target_answer" => Some("answer"), "dispatch_target_append" => Some("append"), - "dispatch_target_workspace_begin" => Some("__workspace_begin"), - "dispatch_target_workspace_chunk" => Some("__workspace_chunk"), - "dispatch_target_workspace_commit" => Some("__workspace_commit"), - "dispatch_target_workspace_result" => Some("__workspace_result"), - "dispatch_target_workspace_result_chunk" => Some("__workspace_result_chunk"), + "dispatch_target_workspace_provision" => Some("__workspace_provision"), + "dispatch_target_workspace_bundle_begin" => Some("__workspace_bundle_begin"), + "dispatch_target_workspace_bundle_chunk" => Some("__workspace_bundle_chunk"), + "dispatch_target_workspace_bundle_commit" => Some("__workspace_bundle_commit"), + "dispatch_target_workspace_sync" => Some("__workspace_sync"), + "dispatch_target_workspace_sync_chunk" => Some("__workspace_sync_chunk"), _ => None, } } diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index e771f2fc99..aaead92a47 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -105,8 +105,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_submit", "dispatch_status", "dispatch_cancel", - "dispatch_pull_result", - "dispatch_apply_result", + "dispatch_sync_result", "dispatch_list_jobs", "dispatch_answer", "dispatch_append", diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 1107fe5b6d..bf4a34f805 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -362,11 +362,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::WorkspaceAgnostic, ), ( - "dispatch_apply_result", - RemoteWorkspacePolicy::WorkspaceAgnostic, - ), - ( - "dispatch_pull_result", + "dispatch_sync_result", RemoteWorkspacePolicy::WorkspaceAgnostic, ), ( diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 6e29c8d85d..6dff7f5834 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1755,8 +1755,7 @@ pub async fn run() { 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_sync_result, api::dispatch_api::dispatch_list_jobs, api::dispatch_api::dispatch_answer, api::dispatch_api::dispatch_append, diff --git a/src/apps/server/src/routes/dispatch.rs b/src/apps/server/src/routes/dispatch.rs index 52abddf68d..f17e273290 100644 --- a/src/apps/server/src/routes/dispatch.rs +++ b/src/apps/server/src/routes/dispatch.rs @@ -10,12 +10,12 @@ use bitfun_core::external_sources::{ use bitfun_core::service::dispatch::{ answer_dispatch, append_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, - sync_dispatch_model_config, DispatchAnswerRequest, + probe_dispatch_target, start_dispatch_cli_install, start_dispatch_cli_source_build, + submit_dispatch, sync_dispatch_model_config, sync_dispatch_result, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, DispatchStatusRequest, - DispatchSubmitRequest, OutboundDispatchStore, + DispatchSubmitRequest, DispatchSyncResultRequest, OutboundDispatchStore, }; use serde::de::DeserializeOwned; @@ -27,11 +27,13 @@ pub(crate) fn supports(method: &str) -> bool { "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_sync_result" | "dispatch_cancel" | "dispatch_list_jobs" | "dispatch_answer" @@ -77,6 +79,14 @@ pub(crate) async fn dispatch( .map_err(operation_error)?, ) } + "dispatch_install_cli_source_start" => { + let request = parse_request::(¶ms)?; + encode( + start_dispatch_cli_source_build(&host.ssh_manager, request) + .await + .map_err(operation_error)?, + ) + } "dispatch_install_cli_poll" => { let request = parse_request::(¶ms)?; encode( @@ -111,6 +121,12 @@ pub(crate) async fn dispatch( .await .map_err(operation_error) } + "dispatch_sync_result" => { + let request = parse_request::(¶ms)?; + sync_dispatch_result(&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) @@ -186,11 +202,13 @@ mod tests { "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_sync_result", "dispatch_cancel", "dispatch_list_jobs", "dispatch_answer", @@ -207,4 +225,14 @@ mod tests { let error = parse_request::(&serde_json::json!({})).unwrap_err(); assert_eq!(error.code, ExternalSourceOperationErrorCode::InvalidRequest); } + + #[test] + fn source_build_route_accepts_a_structured_connection_request() { + let request = parse_request::(&serde_json::json!({ + "request": { "connectionId": "ssh-target" } + })) + .unwrap(); + + assert_eq!(request.connection_id, "ssh-target"); + } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs index 7932eaf219..9cadc3e244 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs @@ -373,6 +373,8 @@ The tool cannot remove or rebind the worktree in which it is running. Use Sessio source_workspace_path: Some(source_workspace_path), base_ref: input.base_ref, copy_local_changes: input.copy_local_changes, + // The session this operation creates is the claim. + claimed_by: None, }) .await .map_err(|error| BitFunError::tool(error.to_string()))?; diff --git a/src/crates/assembly/core/src/service/dispatch/baseline.rs b/src/crates/assembly/core/src/service/dispatch/baseline.rs new file mode 100644 index 0000000000..0df0e0e144 --- /dev/null +++ b/src/crates/assembly/core/src/service/dispatch/baseline.rs @@ -0,0 +1,753 @@ +//! Controller-side Git baseline for one dispatch. +//! +//! Every dispatch branches from a managed worktree of the controller's own +//! repository. That worktree is what makes the target's result a normal branch +//! instead of a pile of overwritten files: both sides share `base_commit`, so +//! syncing back is a fast-forward the user can inspect, merge, or throw away. +//! +//! Nothing here is transport-specific. SSH and account-device dispatch differ +//! only in how the bundle bytes travel. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result}; +use sha2::{Digest, Sha256}; + +use crate::service::git::{execute_git_command, GitService}; +use crate::service::worktree::{ + WorktreeCreateBranchRequest, WorktreeCreateRequest, WorktreeService, +}; + +use super::{ + baseline_claim, DispatchWorkspaceDelivery, OutboundDispatchRecord, OutboundDispatchStore, +}; + +/// Number of hex characters used to name a target's shared clone. +const REPO_KEY_CHARS: usize = 16; +/// Commit written when the user asked to carry uncommitted work along. +const UNCOMMITTED_COMMIT_MESSAGE: &str = "BitFun dispatch: uncommitted baseline changes"; + +#[derive(Debug, Clone)] +pub(super) struct PreparedBaseline { + pub(super) delivery: DispatchWorkspaceDelivery, + /// Absolute path of the managed worktree on this controller. + pub(super) worktree_path: String, + /// Names the shared clone on the target. Derived, never user-supplied. + pub(super) repo_key: String, +} + +#[derive(Debug, Clone)] +pub(super) struct PreparedBundle { + pub(super) path: PathBuf, + pub(super) sha256: String, + pub(super) size: u64, +} + +/// Create (or reopen) this job's baseline worktree and describe its delivery. +/// +/// Idempotent through `WorktreeService`'s own receipt: an ambiguous submit that +/// is retried with the same job id reuses the same worktree and the same base +/// commit, so a retry can never hand the target a different tree than the one +/// the first attempt may already have committed to. +pub(super) async fn prepare_baseline( + store: &OutboundDispatchStore, + job_id: &str, + source_workspace_path: &str, + base_ref: Option<&str>, + include_uncommitted: bool, +) -> Result { + let project = source_workspace_path.trim(); + if project.is_empty() { + anyhow::bail!("dispatch requires the controller workspace that owns the session"); + } + let repository = GitService::resolve_worktree_repository(Path::new(project)) + .await + .map_err(|error| { + anyhow::anyhow!("dispatch requires a Git workspace on this machine: {error}") + })?; + + let settings = WorktreeService::settings().await; + let branch = dispatch_branch_name(&settings.branch_prefix, job_id); + + let created = WorktreeService::create(WorktreeCreateRequest { + request_id: job_id.to_string(), + project_workspace_path: project.to_string(), + source_workspace_path: Some(project.to_string()), + base_ref: base_ref + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + // Carrying uncommitted work needs it in the worktree first; the commit + // below is what actually makes it reachable by the target. + copy_local_changes: include_uncommitted, + claimed_by: Some(baseline_claim(job_id)), + }) + .await + .map_err(|error| anyhow::anyhow!("prepare the dispatch baseline worktree: {error}"))?; + + let worktree_path = created.worktree.path.clone(); + let worktree_id = created.worktree.worktree_id.clone(); + let project_workspace_path = created.worktree.project_workspace_path.trim().to_string(); + let project_workspace_path = if project_workspace_path.is_empty() { + project.to_string() + } else { + project_workspace_path + }; + let prepared = async { + if created.worktree.branch.as_deref() != Some(branch.as_str()) { + WorktreeService::create_branch(WorktreeCreateBranchRequest { + request_id: format!("{job_id}::branch"), + project_workspace_path: project_workspace_path.clone(), + worktree_id: worktree_id.clone(), + branch: branch.clone(), + }) + .await + .map_err(|error| anyhow::anyhow!("create the dispatch branch: {error}"))?; + } + + if include_uncommitted { + commit_uncommitted_changes(&worktree_path).await?; + } + + let base_commit = GitService::resolve_revision(&worktree_path, "HEAD") + .await + .map_err(|error| anyhow::anyhow!("resolve the dispatch base commit: {error}"))?; + let remote_url = resolve_remote_url(project).await; + let repo_key = repo_key(remote_url.as_deref(), &repository.common_git_dir); + + Ok(PreparedBaseline { + delivery: DispatchWorkspaceDelivery { + source_workspace_path: project.to_string(), + project_workspace_path: project_workspace_path.clone(), + baseline_worktree_id: worktree_id.clone(), + base_commit, + branch: branch.clone(), + remote_url, + include_uncommitted, + }, + worktree_path, + repo_key, + }) + } + .await; + + match prepared { + Ok(baseline) => Ok(baseline), + Err(error) => { + release_baseline_claim_if_unowned( + store, + job_id, + &project_workspace_path, + &worktree_id, + &branch, + ) + .await; + Err(error) + } + } +} + +/// Release the retention claim when setup fails before an outbound record can +/// take ownership of it. The managed worktree is intentionally retained: it +/// may contain the WIP baseline commit and the normal worktree retention rules +/// can decide when it is safe to remove. +pub(super) async fn release_prepared_baseline( + store: &OutboundDispatchStore, + job_id: &str, + baseline: &PreparedBaseline, +) { + release_baseline_claim_if_unowned( + store, + job_id, + &baseline.delivery.project_workspace_path, + &baseline.delivery.baseline_worktree_id, + &baseline.delivery.branch, + ) + .await; +} + +/// The persisted record, rather than the current submit attempt, owns the +/// baseline claim once all immutable Git identity fields match. +pub(super) fn outbound_record_owns_baseline( + record: &OutboundDispatchRecord, + worktree_id: &str, + base_commit: &str, + branch: &str, +) -> bool { + record.baseline_worktree_id.as_deref() == Some(worktree_id) + && record.base_commit.as_deref() == Some(base_commit) + && record.branch.as_deref() == Some(branch) +} + +/// A durable record conservatively owns the retention claim when it identifies +/// this exact worktree and job branch. +/// +/// The commit is deliberately not part of this cleanup predicate. A retry that +/// copied uncommitted changes may observe the receipt's original base before it +/// refreshes the worktree HEAD, while the durable record already contains the +/// generated WIP commit. In that ambiguous state preserving a valid claim is +/// safer than allowing automatic cleanup to delete the record's baseline. +fn outbound_record_may_own_claim( + record: &OutboundDispatchRecord, + worktree_id: &str, + branch: &str, +) -> bool { + record.baseline_worktree_id.as_deref() == Some(worktree_id) + && record.branch.as_deref() == Some(branch) +} + +async fn release_baseline_claim_if_unowned( + store: &OutboundDispatchStore, + job_id: &str, + project_workspace_path: &str, + worktree_id: &str, + branch: &str, +) { + match store.get(job_id).await { + Ok(Some(record)) if outbound_record_may_own_claim(&record, worktree_id, branch) => { + return; + } + Ok(_) => {} + Err(error) => { + // A read failure makes ownership ambiguous. Preserve the claim and + // its durable owner rather than risking automatic baseline deletion. + log::warn!( + "Could not determine dispatch baseline claim ownership: job_id={} worktree_id={} error={}", + job_id, + worktree_id, + error + ); + return; + } + } + + if let Err(error) = WorktreeService::release_claim_for_worktree( + project_workspace_path, + worktree_id, + &baseline_claim(job_id), + ) + .await + { + log::warn!( + "Failed to release unbound dispatch baseline claim: job_id={} worktree_id={} error={}", + job_id, + worktree_id, + error + ); + } +} + +/// Package the objects a target is missing. +/// +/// `have_tips` comes from the target itself rather than from this machine's +/// remote-tracking refs. A controller that assumed "the remote has it, so the +/// target has it" would ship a bundle with prerequisites the target cannot +/// resolve whenever the target's clone is stale or its network is down. +pub(super) async fn build_base_bundle( + store: &OutboundDispatchStore, + baseline: &PreparedBaseline, + have_tips: &[String], +) -> Result { + let bundles = store.bundles_dir().await?; + let path = bundles.join(format!( + "{}.base.bundle", + sanitized_stem(&baseline.delivery.branch) + )); + remove_if_present(&path)?; + + let mut args: Vec = vec![ + "bundle".to_string(), + "create".to_string(), + path.to_string_lossy().to_string(), + // `git bundle create` needs a named ref to advertise; a raw commit SHA + // is treated as an unadvertised object and Git refuses the resulting + // empty bundle. The branch is job-scoped and points at `base_commit`. + format!("refs/heads/{}", baseline.delivery.branch), + ]; + let known_tips = retain_known_commits(&baseline.worktree_path, have_tips).await; + if !known_tips.is_empty() { + args.push("--not".to_string()); + args.extend(known_tips); + } + let borrowed = args.iter().map(String::as_str).collect::>(); + execute_git_command(&baseline.worktree_path, &borrowed) + .await + .map_err(|error| anyhow::anyhow!("package the dispatch base bundle: {error}"))?; + + finish_bundle(path) +} + +/// Verify that sync-back is still operating on the branch this job owns. +/// +/// A path alone is not enough identity: a user can switch the managed worktree +/// to another branch (or detach it) while the dispatch is running. Fetching and +/// merging in that state would advance the wrong checkout. +pub(super) async fn ensure_baseline_branch( + worktree_path: &str, + expected_branch: &str, +) -> Result<()> { + let current = execute_git_command( + worktree_path, + &["symbolic-ref", "--quiet", "--short", "HEAD"], + ) + .await + .map_err(|error| { + anyhow::anyhow!( + "the dispatch baseline is detached or its symbolic branch cannot be read: {error}" + ) + })?; + let current = current.trim(); + if current != expected_branch { + anyhow::bail!( + "the dispatch baseline is on branch '{current}', expected '{expected_branch}'; switch it back before syncing" + ); + } + Ok(()) +} + +/// Fast-forward the baseline worktree onto the branch the target produced. +/// +/// `--ff-only` is the whole safety story: the baseline branch is created for +/// this job and nothing else writes it, so a rejected fast-forward means the +/// user committed into the baseline themselves. Refusing loudly is correct — +/// silently merging or resetting would discard their work. +pub(super) async fn fetch_result_bundle( + worktree_path: &str, + branch: &str, + bundle: &Path, +) -> Result { + let bundle_arg = bundle.to_string_lossy().to_string(); + execute_git_command(worktree_path, &["bundle", "verify", &bundle_arg]) + .await + .map_err(|error| anyhow::anyhow!("verify the dispatch result bundle: {error}"))?; + execute_git_command( + worktree_path, + &[ + "fetch", + "--no-tags", + &bundle_arg, + &format!("refs/heads/{branch}"), + ], + ) + .await + .map_err(|error| anyhow::anyhow!("fetch the dispatch result bundle: {error}"))?; + execute_git_command(worktree_path, &["merge", "--ff-only", "FETCH_HEAD"]) + .await + .map_err(|error| { + anyhow::anyhow!( + "fast-forward the dispatch baseline worktree: {error}. \ + The baseline has its own commits, so the target's branch was left in FETCH_HEAD \ + for you to merge manually." + ) + })?; + Ok(execute_git_command(worktree_path, &["rev-parse", "HEAD"]) + .await + .map_err(|error| anyhow::anyhow!("read the synced baseline head: {error}"))? + .trim() + .to_string()) +} + +/// Whether `base_commit` is already reachable from a remote-tracking ref. +/// +/// A hint only: the target has the final say through `needsBundle`, because +/// only it knows what its own clone can reach. +pub(super) async fn base_commit_is_published(worktree_path: &str, base_commit: &str) -> bool { + execute_git_command( + worktree_path, + &[ + "rev-list", + "--count", + "--max-count=1", + base_commit, + "--not", + "--remotes", + ], + ) + .await + .map(|output| output.trim() == "0") + .unwrap_or(false) +} + +async fn commit_uncommitted_changes(worktree_path: &str) -> Result<()> { + execute_git_command(worktree_path, &["add", "-A"]) + .await + .map_err(|error| anyhow::anyhow!("stage the dispatch baseline changes: {error}"))?; + if execute_git_command(worktree_path, &["diff", "--cached", "--name-only"]) + .await + .map(|output| output.trim().is_empty()) + .unwrap_or(true) + { + return Ok(()); + } + execute_git_command( + worktree_path, + &[ + "-c", + "commit.gpgsign=false", + "-c", + "user.name=BitFun Dispatch", + "-c", + "user.email=dispatch@bitfun.local", + "commit", + "--no-verify", + "-m", + UNCOMMITTED_COMMIT_MESSAGE, + ], + ) + .await + .map_err(|error| anyhow::anyhow!("commit the dispatch baseline changes: {error}"))?; + Ok(()) +} + +async fn resolve_remote_url(project: &str) -> Option { + for args in [ + vec!["remote", "get-url", "--push", "origin"], + vec!["remote", "get-url", "origin"], + ] { + if let Ok(output) = execute_git_command(project, &args).await { + let url = output.trim(); + if !url.is_empty() { + return Some(url.to_string()); + } + } + } + // No `origin`: fall back to whichever remote the repository does define, so + // a repo using a differently named remote still gets the fast path. + let remotes = execute_git_command(project, &["remote"]).await.ok()?; + let first = remotes + .lines() + .map(str::trim) + .find(|name| !name.is_empty())?; + let url = execute_git_command(project, &["remote", "get-url", first]) + .await + .ok()?; + let url = url.trim(); + (!url.is_empty()).then(|| url.to_string()) +} + +async fn retain_known_commits(worktree_path: &str, tips: &[String]) -> Vec { + let mut known = Vec::new(); + for tip in tips { + let tip = tip.trim(); + if tip.len() != 40 || !tip.bytes().all(|byte| byte.is_ascii_hexdigit()) { + continue; + } + if execute_git_command( + worktree_path, + &["cat-file", "-e", &format!("{tip}^{{commit}}")], + ) + .await + .is_ok() + { + known.push(tip.to_string()); + } + } + known +} + +fn finish_bundle(path: PathBuf) -> Result { + let size = std::fs::symlink_metadata(&path) + .with_context(|| format!("inspect dispatch bundle {}", path.display()))? + .len(); + let sha256 = bitfun_services_core::dispatch_workspace::sha256_file(&path)?; + Ok(PreparedBundle { path, sha256, size }) +} + +fn remove_if_present(path: &Path) -> Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("replace bundle {}", path.display())), + } +} + +/// Branch that carries this dispatch's work on both machines. +/// +/// The user-configurable prefix is sanitized rather than trusted: it reaches a +/// `git update-ref` argument on the target, and the job id suffix is what keeps +/// concurrent dispatches on one repository from colliding. +fn dispatch_branch_name(branch_prefix: &str, job_id: &str) -> String { + // Rebuild the prefix segment by segment. `.` and `..` segments are dropped + // rather than escaped: they are invalid in a Git ref and are also the shape + // a path traversal would take on the target. + let prefix = branch_prefix + .split('/') + .map(|segment| { + segment + .chars() + .filter(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + .collect::() + }) + .map(|segment| { + segment + .trim_matches('.') + .trim_start_matches('-') + .to_string() + }) + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("/"); + let suffix = job_id + .chars() + .filter(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + .take(24) + .collect::(); + let suffix = if suffix.is_empty() { + "job".to_string() + } else { + suffix + }; + if prefix.is_empty() { + format!("dispatch/{suffix}") + } else { + format!("{prefix}/dispatch/{suffix}") + } +} + +fn sanitized_stem(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '-' + } + }) + .collect() +} + +/// Stable directory name for the target's shared clone. +/// +/// Keyed on the remote URL when there is one, so unrelated controllers cloning +/// the same repository reuse one clone on a shared target. Without a remote the +/// repositories are unrelated by construction, so the controller's own Git +/// directory keys it instead. +fn repo_key(remote_url: Option<&str>, common_git_dir: &Path) -> String { + let mut digest = Sha256::new(); + digest.update(b"bitfun-dispatch-repo"); + match remote_url { + Some(url) => { + digest.update(b"remote:"); + digest.update(url.trim().as_bytes()); + } + None => { + digest.update(b"local:"); + digest.update(common_git_dir.to_string_lossy().as_bytes()); + } + } + format!("{:x}", digest.finalize()) + .chars() + .take(REPO_KEY_CHARS) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn git(path: &Path, args: &[&str]) -> String { + let output = std::process::Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + + #[test] + fn branch_names_are_prefixed_scoped_and_ref_safe() { + assert_eq!( + dispatch_branch_name("bitfun/", "dispatch-1a2b3c"), + "bitfun/dispatch/dispatch-1a2b3c" + ); + assert_eq!(dispatch_branch_name("", "job1"), "dispatch/job1"); + // A prefix that could be read as a git option or escape a ref namespace + // is sanitized rather than rejected: the setting is cosmetic and must + // not be able to break every dispatch. + assert_eq!( + dispatch_branch_name("--upload-pack=x", "job1"), + "upload-packx/dispatch/job1" + ); + assert_eq!( + dispatch_branch_name("../../etc", "job1"), + "etc/dispatch/job1" + ); + assert_eq!(dispatch_branch_name("///", "job1"), "dispatch/job1"); + } + + #[test] + fn repo_keys_are_hex_stable_and_separate_remote_from_local_identity() { + let remote = repo_key(Some("git@example.com:acme/app.git"), Path::new("/a/.git")); + let same_remote = repo_key(Some("git@example.com:acme/app.git"), Path::new("/b/.git")); + let local = repo_key(None, Path::new("/a/.git")); + + assert_eq!( + remote, same_remote, + "one remote must map to one shared clone" + ); + assert_ne!(remote, local); + assert_eq!(remote.len(), REPO_KEY_CHARS); + assert!(remote.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + + #[tokio::test] + async fn base_bundle_advertises_the_job_branch_instead_of_an_empty_raw_sha() { + let temp = tempfile::tempdir().expect("tempdir"); + let repository = temp.path().join("repository"); + std::fs::create_dir_all(&repository).expect("repository"); + git(&repository, &["init", "--quiet", "--initial-branch=main"]); + git(&repository, &["config", "user.name", "Dispatch Test"]); + git( + &repository, + &["config", "user.email", "dispatch@example.com"], + ); + std::fs::write(repository.join("file.txt"), b"base").expect("seed"); + git(&repository, &["add", "-A"]); + git(&repository, &["commit", "--quiet", "-m", "base"]); + let branch = "bitfun/dispatch/job-1"; + git(&repository, &["branch", branch]); + let base_commit = git(&repository, &["rev-parse", "HEAD"]); + let store = + OutboundDispatchStore::new_in_root_for_tests(temp.path().join("dispatch-outbound")); + let baseline = PreparedBaseline { + delivery: DispatchWorkspaceDelivery { + source_workspace_path: repository.to_string_lossy().to_string(), + project_workspace_path: repository.to_string_lossy().to_string(), + baseline_worktree_id: "worktree-1".to_string(), + base_commit, + branch: branch.to_string(), + remote_url: None, + include_uncommitted: false, + }, + worktree_path: repository.to_string_lossy().to_string(), + repo_key: "abcdef0123456789".to_string(), + }; + + let bundle = build_base_bundle(&store, &baseline, &[]) + .await + .expect("base bundle"); + git( + &repository, + &[ + "bundle", + "verify", + bundle.path.to_str().expect("bundle path"), + ], + ); + let heads = git( + &repository, + &[ + "bundle", + "list-heads", + bundle.path.to_str().expect("bundle path"), + ], + ); + assert!(heads.contains(&format!("refs/heads/{branch}"))); + } + + #[test] + fn durable_baseline_ownership_requires_all_immutable_git_identity() { + let mut record = OutboundDispatchRecord::new( + "job-1".to_string(), + super::super::DispatchTarget::Local, + "session-1".to_string(), + "/target".to_string(), + "prompt", + "submitting", + ) + .expect("record"); + record.baseline_worktree_id = Some("worktree-1".to_string()); + record.base_commit = Some("0123456789abcdef".to_string()); + record.branch = Some("bitfun/dispatch/job-1".to_string()); + + assert!(outbound_record_owns_baseline( + &record, + "worktree-1", + "0123456789abcdef", + "bitfun/dispatch/job-1" + )); + assert!(!outbound_record_owns_baseline( + &record, + "different-worktree", + "0123456789abcdef", + "bitfun/dispatch/job-1" + )); + assert!(!outbound_record_owns_baseline( + &record, + "worktree-1", + "different-commit", + "bitfun/dispatch/job-1" + )); + assert!(!outbound_record_owns_baseline( + &record, + "worktree-1", + "0123456789abcdef", + "different-branch" + )); + + // Claim cleanup is intentionally more conservative than binding: an + // idempotent WIP retry can briefly observe the receipt's original HEAD + // even though this durable record owns the later generated commit. + assert!(outbound_record_may_own_claim( + &record, + "worktree-1", + "bitfun/dispatch/job-1" + )); + record.base_commit = Some("generated-wip-commit".to_string()); + assert!(outbound_record_may_own_claim( + &record, + "worktree-1", + "bitfun/dispatch/job-1" + )); + assert!(!outbound_record_may_own_claim( + &record, + "different-worktree", + "bitfun/dispatch/job-1" + )); + } + + #[tokio::test] + async fn baseline_branch_guard_rejects_wrong_and_detached_checkouts() { + let temp = tempfile::tempdir().expect("tempdir"); + let repository = temp.path().join("repository"); + std::fs::create_dir_all(&repository).expect("repository"); + git(&repository, &["init", "--quiet", "--initial-branch=main"]); + git(&repository, &["config", "user.name", "Dispatch Test"]); + git( + &repository, + &["config", "user.email", "dispatch@example.com"], + ); + std::fs::write(repository.join("file.txt"), b"base").expect("seed"); + git(&repository, &["add", "-A"]); + git(&repository, &["commit", "--quiet", "-m", "base"]); + let branch = "bitfun/dispatch/job-branch"; + git(&repository, &["switch", "--quiet", "-c", branch]); + + ensure_baseline_branch(repository.to_str().expect("repository path"), branch) + .await + .expect("owned branch"); + + git(&repository, &["switch", "--quiet", "main"]); + let wrong = ensure_baseline_branch(repository.to_str().expect("repository path"), branch) + .await + .expect_err("wrong branch"); + assert!(wrong.to_string().contains("baseline is on branch 'main'")); + + git(&repository, &["switch", "--quiet", "--detach"]); + let detached = + ensure_baseline_branch(repository.to_str().expect("repository path"), branch) + .await + .expect_err("detached baseline"); + assert!(detached.to_string().contains("baseline is detached")); + } +} diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index ab39eefbd0..9ec243a604 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -1,7 +1,3 @@ -use anyhow::Context as _; -use bitfun_services_core::dispatch_workspace::{ - apply_workspace_result_bundle, WorkspaceResultApplyOutcome, WorkspaceResultSummary, -}; use bitfun_services_integrations::remote_ssh::{ dispatch_ssh::{ self, DispatchCliRelease, DispatchInstallPoll, DispatchInstallStart, DispatchSshProbe, @@ -11,12 +7,19 @@ use bitfun_services_integrations::remote_ssh::{ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use crate::service::worktree::WorktreeService; + +use super::baseline::{ + base_commit_is_published, build_base_bundle, ensure_baseline_branch, fetch_result_bundle, + outbound_record_owns_baseline, prepare_baseline, release_prepared_baseline, PreparedBaseline, +}; +use super::preparation::{DispatchPreparationRequest, DispatchPreparationTarget}; use super::{ - adopt_target_jobs, DispatchTarget, DispatchTargetRequest, DispatchWorkspaceDeliveryRequest, - DispatchWorkspaceSnapshotCaptureMode, OutboundDispatchRecord, OutboundDispatchStore, + adopt_target_jobs, DispatchTarget, DispatchTargetRequest, OutboundDispatchRecord, + OutboundDispatchStore, }; -pub(super) const DISPATCH_PROTOCOL_VERSION: u64 = 2; +pub(super) const DISPATCH_PROTOCOL_VERSION: u64 = 3; pub(super) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024; #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -54,8 +57,13 @@ pub struct DispatchInstallPollRequest { #[serde(rename_all = "camelCase")] pub struct DispatchSubmitRequest { pub target: DispatchTargetRequest, + /// Revision used to create the controller baseline worktree. It is + /// resolved once, then both sides use the resulting immutable commit. #[serde(default)] - pub workspace_delivery: DispatchWorkspaceDeliveryRequest, + pub base_ref: Option, + /// Carry the baseline worktree's uncommitted changes into `base_commit`. + #[serde(default)] + pub include_uncommitted: bool, pub job_id: String, pub session_id: String, pub agent_type: String, @@ -88,13 +96,11 @@ pub struct DispatchJobRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct DispatchApplyResultRequest { +pub struct DispatchSyncResultRequest { pub job_id: String, - /// Local workspace the bundle is applied to. - pub workspace_path: String, - /// Take the target's version for paths that changed on both sides. + /// Commit message used when the target still has uncommitted changes. #[serde(default)] - pub overwrite_conflicts: bool, + pub message: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] @@ -144,8 +150,6 @@ pub struct DispatchTargetOption { #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub default_workspace: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub online: Option, } @@ -159,7 +163,6 @@ pub async fn list_targets( device_id: None, display_name: "Local".to_string(), description: None, - default_workspace: None, online: None, }]; targets.extend( @@ -176,7 +179,6 @@ pub async fn list_targets( "{}@{}:{}", connection.username, connection.host, connection.port )), - default_workspace: connection.default_workspace, online: None, }), ); @@ -277,7 +279,9 @@ pub async fn submit( let DispatchTargetRequest::Ssh { connection_id, - workspace_path: requested_workspace_path, + // The target path is the target's business now: dispatch always checks + // out its own worktree there rather than reusing a directory. + workspace_path: _, } = &request.target else { anyhow::bail!("SSH dispatch submission requires an SSH target"); @@ -285,38 +289,152 @@ pub async fn submit( if connection_id.trim().is_empty() { anyhow::bail!("SSH dispatch requires a connectionId"); } - let workspace_path = resolve_ssh_workspace( - manager, - store, - connection_id, - requested_workspace_path, - &request.workspace_delivery, - &request.job_id, - ) - .await?; - // 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(|| { + let source_workspace_path = request + .source_workspace_path + .as_deref() + .unwrap_or_default() + .trim(); + let project_workspace_path = + WorktreeService::resolve_project_workspace_path(source_workspace_path) + .await + .map_err(|error| anyhow::anyhow!("resolve the dispatch project workspace: {error}"))?; + // One controller process at a time may install/provision/submit a job. The + // JSON journal uses a different lock, so audit events can still be appended + // atomically while this long-lived guard is held. + let _preparation_run_lock = store.acquire_preparation_run_lock(&request.job_id).await?; + if let Some(existing) = store.get(&request.job_id).await? { + if existing.session_id != request.session_id + || !matches!( + &existing.target, + DispatchTarget::Ssh { + connection_id: existing_connection, + .. + } if existing_connection == connection_id + ) + { + anyhow::bail!("Dispatch jobId is already bound to another target or session"); + } + } + store + .begin_preparation(DispatchPreparationRequest { + job_id: request.job_id.clone(), + session_id: request.session_id.clone(), + target: DispatchPreparationTarget::ssh(connection_id.clone()), + source_workspace_path: source_workspace_path.to_string(), + project_workspace_path, + }) + .await?; + + // Re-check the executable that will receive this submission, installing it + // when missing. The picker probe can be stale, and headless callers bypass + // the UI entirely. Every audit event is durably journaled before the + // corresponding remote mutation, then replayed into the target event log. + let audit_attempt = uuid::Uuid::new_v4().as_simple().to_string(); + let mut audit_sequence = 0_u32; + let audit_store = store.clone(); + let audit_job_id = request.job_id.clone(); + let cli_probe = + dispatch_ssh::ensure_target_cli(manager, connection_id, move |stage, release| { + audit_sequence = audit_sequence.saturating_add(1); + let event_id = format!("{audit_attempt}:{audit_sequence}"); + let stage = stage.to_string(); + let audit_store = audit_store.clone(); + let audit_job_id = audit_job_id.clone(); + async move { + log::info!("Dispatch SSH CLI install: stage={stage} details={release}"); + audit_store + .append_preparation_setup_audit( + &audit_job_id, + &event_id, + json!({ + "timestamp": chrono::Utc::now().to_rfc3339(), + "action": "cli-install", + "details": { + "stage": stage, + "release": release, + }, + }), + ) + .await + } + }) + .await?; + recover_interrupted_cli_install_audit(store, &request.job_id, &cli_probe).await?; + let cli_protocol = cli_probe.protocol.as_ref().ok_or_else(|| { anyhow::anyhow!( "{}", - preflight + cli_probe .protocol_error .as_deref() - .or(preflight.install_error.as_deref()) + .or(cli_probe.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 workspace_path = protocol - .pointer("/workspace/path") - .and_then(Value::as_str) - .filter(|path| !path.trim().is_empty()) - .unwrap_or(workspace_path.as_str()) - .to_string(); + dispatch_ssh::validate_dispatch_protocol(cli_protocol, Some(&request.approval_policy))?; + + let baseline = prepare_baseline( + store, + &request.job_id, + source_workspace_path, + request.base_ref.as_deref(), + request.include_uncommitted, + ) + .await?; + if let Err(error) = store + .attach_preparation_baseline( + &request.job_id, + &baseline.delivery.baseline_worktree_id, + &baseline.delivery.branch, + ) + .await + { + release_prepared_baseline(store, &request.job_id, &baseline).await; + return Err(error); + } + store.touch_preparation(&request.job_id).await?; + + let workspace_path = + match provision_ssh_workspace(manager, store, connection_id, &request.job_id, &baseline) + .await + { + Ok(path) => path, + Err(error) => { + release_unbound_preparation_baseline(store, &request.job_id, &baseline).await; + return Err(error); + } + }; + store.touch_preparation(&request.job_id).await?; + + // Provision owns the canonical target path. Probe that exact worktree so + // model readiness and Git identity are checked immediately before submit. + let workspace_probe = + match dispatch_ssh::probe(manager, connection_id, Some(&workspace_path)).await { + Ok(probe) => probe, + Err(error) => { + release_unbound_preparation_baseline(store, &request.job_id, &baseline).await; + return Err(error); + } + }; + let protocol = match workspace_probe.protocol.as_ref() { + Some(protocol) => protocol, + None => { + release_unbound_preparation_baseline(store, &request.job_id, &baseline).await; + anyhow::bail!( + "{}", + workspace_probe.protocol_error.as_deref().unwrap_or( + "BitFun CLI dispatch protocol is unavailable in the target worktree" + ) + ); + } + }; + if let Err(error) = + dispatch_ssh::validate_dispatch_protocol(protocol, Some(&request.approval_policy)) + .and_then(|_| validate_submission_preflight(protocol, request.model.as_deref())) + { + release_unbound_preparation_baseline(store, &request.job_id, &baseline).await; + return Err(error); + } let display_name = manager .get_saved_connections() @@ -331,14 +449,20 @@ pub async fn submit( display_name, }; - let requested_record = OutboundDispatchRecord::new( + let requested_record = match OutboundDispatchRecord::new( request.job_id.clone(), resolved_target, request.session_id.clone(), workspace_path.clone(), &request.prompt, "submitting", - )? + ) { + Ok(record) => record, + Err(error) => { + release_unbound_preparation_baseline(store, &request.job_id, &baseline).await; + return Err(error.into()); + } + } .with_submission_metadata( request.title.clone(), request.agent_type.clone(), @@ -348,13 +472,18 @@ pub async fn submit( .with_source_workspace( request.source_workspace_path.clone(), request.source_workspace_id.clone(), - ); - let bound_record = store.bind_if_absent(&requested_record).await?; + ) + .with_baseline(&baseline.delivery, &baseline.worktree_path); + let bound_record = bind_outbound_record(store, &requested_record, &baseline).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"); } + store + .mark_preparation_outbound_bound(&request.job_id) + .await?; + let setup_audit = store.preparation_setup_audit(&request.job_id).await?; let mut protocol_request = json!({ "protocolVersion": DISPATCH_PROTOCOL_VERSION, @@ -364,6 +493,7 @@ pub async fn submit( "agentType": request.agent_type, "prompt": request.prompt, "approvalPolicy": request.approval_policy, + "setupAudit": setup_audit, }); if let Some(model) = request.model.filter(|value| !value.trim().is_empty()) { protocol_request["model"] = Value::String(model); @@ -398,98 +528,202 @@ pub async fn submit( .unwrap_or("queued") .to_string(); store.update_progress(&request.job_id, 0, state).await?; + if let Err(error) = store.acknowledge_preparation(&request.job_id).await { + // The target ACK is authoritative. Retaining a redundant journal is + // safe and lets status/retry remove it later; failing the already-live + // task here would be misleading. + log::warn!( + "Failed to remove acknowledged dispatch preparation: job_id={} error={}", + request.job_id, + error + ); + } Ok(response) } -async fn resolve_ssh_workspace( +async fn recover_interrupted_cli_install_audit( + store: &OutboundDispatchStore, + job_id: &str, + probe: &DispatchSshProbe, +) -> anyhow::Result<()> { + let events = store.preparation_setup_audit(job_id).await?; + if events.is_empty() { + return Ok(()); + } + let last_stage = events.iter().rev().find_map(|event| { + event + .pointer("/details/stage") + .and_then(Value::as_str) + .map(str::trim) + .filter(|stage| !stage.is_empty()) + }); + if last_stage == Some("cli-install-succeeded") { + return Ok(()); + } + let version = probe + .protocol + .as_ref() + .and_then(|protocol| protocol.get("cliVersion")) + .and_then(Value::as_str) + .unwrap_or("unknown"); + store + .append_preparation_setup_audit( + job_id, + &format!("recovered-{}", uuid::Uuid::new_v4().as_simple()), + json!({ + "timestamp": chrono::Utc::now().to_rfc3339(), + "action": "cli-install", + "details": { + "stage": "cli-install-succeeded", + "release": { + "version": version, + "cliPath": probe.cli_path, + "recovered": true, + }, + }, + }), + ) + .await +} + +pub(super) async fn release_unbound_preparation_baseline( + store: &OutboundDispatchStore, + job_id: &str, + baseline: &PreparedBaseline, +) { + release_prepared_baseline(store, job_id, baseline).await; + if let Err(error) = store.clear_preparation_baseline(job_id).await { + // Keep the exact journal on any ambiguity. Expiry reconciliation can + // retry the release, whereas deleting its identity could strand it. + log::warn!( + "Failed to clear released dispatch preparation baseline: job_id={} error={}", + job_id, + error + ); + } +} + +/// Bind the record that takes ownership of a prepared baseline claim. +/// +/// A JSON-store error can be ambiguous (for example, permission hardening can +/// fail after the atomic rename). Re-read before releasing so a durable record +/// never loses the claim that keeps its baseline alive. +pub(super) async fn bind_outbound_record( + store: &OutboundDispatchStore, + record: &OutboundDispatchRecord, + baseline: &PreparedBaseline, +) -> anyhow::Result { + match store.bind_if_absent(record).await { + Ok(bound) + if outbound_record_owns_baseline( + &bound, + &baseline.delivery.baseline_worktree_id, + &baseline.delivery.base_commit, + &baseline.delivery.branch, + ) => + { + Ok(bound) + } + Ok(_) => { + release_prepared_baseline(store, &record.job_id, baseline).await; + anyhow::bail!( + "Dispatch jobId is already bound to a different baseline worktree, commit, or branch" + ); + } + Err(error) => { + // The write may have become durable before permission hardening or + // another post-rename step failed. The ownership-aware release + // helper re-reads the record and preserves a matching claim. + release_prepared_baseline(store, &record.job_id, baseline).await; + Err(error.into()) + } + } +} + +/// Check out the baseline commit on the target, shipping objects if needed. +/// +/// The target answers `needsBundle` when its own clone cannot reach the commit. +/// Only then does anything cross the wire, so the common case — a commit that +/// is already on the shared remote — costs one round trip and no transfer. +async fn provision_ssh_workspace( manager: &SSHConnectionManager, store: &OutboundDispatchStore, connection_id: &str, - requested_workspace_path: &str, - delivery: &DispatchWorkspaceDeliveryRequest, job_id: &str, + baseline: &PreparedBaseline, ) -> anyhow::Result { - match delivery { - DispatchWorkspaceDeliveryRequest::Existing => { - let workspace_path = requested_workspace_path.trim(); - if workspace_path.is_empty() { - anyhow::bail!("existing SSH dispatch requires a workspacePath"); - } - Ok(workspace_path.to_string()) - } - DispatchWorkspaceDeliveryRequest::SnapshotSource { - source_workspace_path, - } => { - let prepared = store - .prepare_workspace_snapshot( - job_id, - source_workspace_path, - DispatchWorkspaceSnapshotCaptureMode::Source, - ) - .await?; - let begin_request = json!({ - "protocolVersion": DISPATCH_PROTOCOL_VERSION, - "jobId": job_id, - "metadata": prepared.metadata, - }); - let committed = dispatch_ssh::upload_workspace_snapshot( - manager, - connection_id, - &begin_request, - &prepared.archive_path, - ) - .await?; - committed - .get("workspacePath") - .and_then(Value::as_str) - .filter(|path| !path.trim().is_empty()) - .map(ToOwned::to_owned) - .ok_or_else(|| { - anyhow::anyhow!( - "dispatch target did not return the materialized workspace path" - ) - }) - } - DispatchWorkspaceDeliveryRequest::SnapshotExact { - source_workspace_path, - sensitive_files_confirmed, - } => { - if !sensitive_files_confirmed { - anyhow::bail!( - "exact workspace snapshot requires confirmation that ignored and sensitive files may be transferred" - ); - } - let prepared = store - .prepare_workspace_snapshot( - job_id, - source_workspace_path, - DispatchWorkspaceSnapshotCaptureMode::Exact, - ) - .await?; - let begin_request = json!({ - "protocolVersion": DISPATCH_PROTOCOL_VERSION, - "jobId": job_id, - "metadata": prepared.metadata, - }); - let committed = dispatch_ssh::upload_workspace_snapshot( - manager, - connection_id, - &begin_request, - &prepared.archive_path, - ) - .await?; - committed - .get("workspacePath") - .and_then(Value::as_str) - .filter(|path| !path.trim().is_empty()) + // Both attempts must send an identical request: the target treats a + // differing request for one job as a conflicting baseline and refuses it. + let request = json!({ + "protocolVersion": DISPATCH_PROTOCOL_VERSION, + "jobId": job_id, + "repoKey": baseline.repo_key, + "remoteUrl": baseline.delivery.remote_url, + "baseCommit": baseline.delivery.base_commit, + "branch": baseline.delivery.branch, + }); + + let response = dispatch_ssh::provision_workspace(manager, connection_id, &request).await?; + if let Some(path) = provisioned_path(&response) { + return Ok(path); + } + if response.get("needsBundle").and_then(Value::as_bool) != Some(true) { + anyhow::bail!("dispatch target neither provisioned a workspace nor asked for a bundle"); + } + + let have_tips = target_have_tips(&response); + if base_commit_is_published(&baseline.worktree_path, &baseline.delivery.base_commit).await { + // Worth saying out loud: the commit is on the remote, so the target + // asking for it means its clone is stale or its network is down. + log::info!( + "Dispatch target could not reach a published base commit; delivering it by bundle" + ); + } + let bundle = build_base_bundle(store, baseline, &have_tips).await?; + let upload = dispatch_ssh::upload_bundle( + manager, + connection_id, + job_id, + &bundle.sha256, + bundle.size, + &bundle.path, + ) + .await; + // The objects are in the target repository now; the local artifact is pure + // duplication of history this machine already owns. Remove it either way so + // a failed upload does not leave a stale bundle behind. + let _ = std::fs::remove_file(&bundle.path); + upload?; + + let response = dispatch_ssh::provision_workspace(manager, connection_id, &request).await?; + provisioned_path(&response).ok_or_else(|| { + anyhow::anyhow!("dispatch target could not check out the base commit after the bundle") + }) +} + +pub(super) fn target_have_tips(response: &Value) -> Vec { + response + .get("haveTips") + .and_then(Value::as_array) + .map(|tips| { + tips.iter() + .filter_map(Value::as_str) .map(ToOwned::to_owned) - .ok_or_else(|| { - anyhow::anyhow!( - "dispatch target did not return the materialized workspace path" - ) - }) - } + .collect() + }) + .unwrap_or_default() +} + +pub(super) fn provisioned_path(response: &Value) -> Option { + if response.get("provisioned").and_then(Value::as_bool) != Some(true) { + return None; } + response + .get("workspacePath") + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) } pub async fn status( @@ -510,6 +744,13 @@ pub async fn status( &json!({ "jobId": request.job_id, "cursor": request.cursor }), ) .await?; + if let Err(error) = store.acknowledge_preparation(&record.job_id).await { + log::warn!( + "Failed to remove dispatch preparation after status confirmation: job_id={} error={}", + record.job_id, + error + ); + } // The request cursor is the last cursor the observer already applied. The // response cursor is deliberately not persisted until the next poll, so a @@ -522,99 +763,95 @@ pub async fn status( store .update_progress(&record.job_id, request.cursor, state) .await?; - // A successful status proves that the target durably owns the job and its - // materialized snapshot. The controller no longer needs the source archive. - let _ = store.remove_workspace_snapshot(&record.job_id).await; Ok(response) } -/// Fetch what a finished snapshot job changed on its target. +/// Bring the target's work back into this controller's baseline worktree. /// -/// Download and inspection only. The bundle lands in this controller's own -/// staging area; nothing touches the user's workspace until they review the -/// reported diff and explicitly apply it. The target tree and the local tree -/// have diverged independently since the snapshot, so silently merging would -/// be the one thing detached execution must never do. -pub async fn pull_result( +/// One button, two halves: the target commits and bundles its branch, then the +/// controller fast-forwards its baseline onto it. There is no conflict handling +/// because there is no conflict to have — both sides share the base commit, so +/// the fetch either fast-forwards or fails loudly because the user committed +/// into the baseline themselves. +pub async fn sync_result( manager: &SSHConnectionManager, store: &OutboundDispatchStore, - request: DispatchJobRequest, + request: DispatchSyncResultRequest, ) -> 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!("SSH dispatch result pull requires an SSH target"); + anyhow::bail!("SSH dispatch sync requires an SSH target"); }; - let destination = result_bundle_path(store, &request.job_id); - let response = - dispatch_ssh::pull_result(manager, connection_id, &request.job_id, &destination).await?; - record_result_summary(store, &request.job_id, &response)?; - Ok(response) + let destination = result_bundle_path(store, &request.job_id).await?; + let response = dispatch_ssh::sync_workspace( + manager, + connection_id, + &request.job_id, + request.message.as_deref(), + record.synced_head_commit.as_deref(), + &destination, + ) + .await?; + finish_sync(store, &record, response, &destination).await } -/// Persist the summary next to the bundle so applying reads both from disk. +/// Fast-forward the baseline worktree and record what was synced. /// -/// The digests that decide whether a local file may be overwritten must come -/// from the verified pull, not from whatever the caller hands back later. -pub(super) fn record_result_summary( +/// Shared by both transports: only fetching the bundle differs between SSH and +/// an account device. +pub(super) async fn finish_sync( store: &OutboundDispatchStore, - job_id: &str, - response: &Value, -) -> anyhow::Result<()> { - if let Some(summary) = response.get("summary") { - // Owner-only like the bundle beside it: this records which paths of the - // user's workspace changed. - let summary_path = result_summary_path(store, job_id); - dispatch_ssh::write_private_file(&summary_path, &serde_json::to_vec(summary)?) - .with_context(|| format!("record result summary {}", summary_path.display()))?; + record: &OutboundDispatchRecord, + mut response: Value, + bundle: &std::path::Path, +) -> anyhow::Result { + if response.get("changed").and_then(Value::as_bool) != Some(true) { + // A clean sync is still an acknowledgement of the target head. Carry + // it into the next request so a later click starts a fresh detached + // sync operation if the still-running agent has since added commits. + if let Some(head) = response.get("headCommit").and_then(Value::as_str) { + store.record_synced_head(&record.job_id, head).await?; + } + return Ok(response); + } + let (Some(worktree_path), Some(branch)) = ( + record.baseline_worktree_path.as_deref(), + record.branch.as_deref(), + ) else { + anyhow::bail!( + "This dispatch has no recorded baseline worktree, so its result cannot be synced. It was submitted before Git-worktree delivery." + ); + }; + if !std::path::Path::new(worktree_path).is_dir() { + anyhow::bail!( + "The baseline worktree for this dispatch is missing ({worktree_path}). Recreate it before syncing." + ); } - Ok(()) -} - -pub(super) fn result_bundle_path( - store: &OutboundDispatchStore, - job_id: &str, -) -> std::path::PathBuf { - store - .root() - .join(super::OUTBOUND_RESULTS_DIR) - .join(format!("{job_id}.tar.gz")) -} -fn result_summary_path(store: &OutboundDispatchStore, job_id: &str) -> std::path::PathBuf { - store - .root() - .join(super::OUTBOUND_RESULTS_DIR) - .join(format!("{job_id}.json")) + ensure_baseline_branch(worktree_path, branch).await?; + let head = fetch_result_bundle(worktree_path, branch, bundle).await?; + store.record_synced_head(&record.job_id, &head).await?; + // The bundle's objects are in the repository now, so keeping the file only + // duplicates history the user already has. + let _ = std::fs::remove_file(bundle); + if let Some(object) = response.as_object_mut() { + object.insert( + "baselineWorktreePath".to_string(), + Value::String(worktree_path.to_string()), + ); + object.insert("syncedHeadCommit".to_string(), Value::String(head)); + } + Ok(response) } -/// Apply a pulled result bundle to a local workspace. -/// -/// Refuses to write anything when a path changed on both sides unless the user -/// explicitly chose to take the target's version. -pub async fn apply_result( +pub(super) async fn result_bundle_path( store: &OutboundDispatchStore, - request: DispatchApplyResultRequest, -) -> anyhow::Result { - let workspace = request.workspace_path.trim(); - if workspace.is_empty() { - anyhow::bail!("Applying dispatch results requires a workspacePath"); - } - let bundle = result_bundle_path(store, &request.job_id); - if !bundle.is_file() { - anyhow::bail!("Pull the dispatch result before applying it"); - } - let summary: WorkspaceResultSummary = - serde_json::from_slice(&std::fs::read(result_summary_path(store, &request.job_id))?) - .context("read recorded dispatch result summary")?; - apply_workspace_result_bundle( - &bundle, - std::path::Path::new(workspace), - &summary, - request.overwrite_conflicts, - ) + job_id: &str, +) -> anyhow::Result { + Ok(store.results_dir().await?.join(format!("{job_id}.bundle"))) } pub async fn cancel( @@ -736,6 +973,13 @@ pub(super) fn validate_submit_request(request: &DispatchSubmitRequest) -> anyhow if request.prompt.len() > MAX_DISPATCH_TEXT_BYTES { anyhow::bail!("Dispatch prompt exceeds the 32 KiB request limit"); } + if request.base_ref.as_ref().is_some_and(|base_ref| { + base_ref.trim().is_empty() + || base_ref.len() > 512 + || base_ref.bytes().any(|byte| byte.is_ascii_control()) + }) { + anyhow::bail!("Dispatch baseRef is invalid"); + } Ok(()) } @@ -827,8 +1071,9 @@ pub(super) fn validate_submission_preflight( .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) + || workspace.get("isGitRepository").and_then(Value::as_bool) != Some(true) { - anyhow::bail!("Dispatch workspace does not exist or is not a directory on the target"); + anyhow::bail!("Dispatch workspace is not a Git worktree on the target"); } if let Some(requested_model) = requested_model .map(str::trim) @@ -891,7 +1136,7 @@ mod tests { #[test] fn submission_preflight_requires_workspace_and_target_model_readiness() { let ready = json!({ - "workspace": { "exists": true, "isDirectory": true }, + "workspace": { "exists": true, "isDirectory": true, "isGitRepository": true }, "modelConfigured": true, "availableModels": ["target-model"] }); @@ -900,14 +1145,14 @@ mod tests { assert!(validate_submission_preflight(&ready, Some("local-only-model")).is_err()); let missing_workspace = json!({ - "workspace": { "exists": false, "isDirectory": false }, + "workspace": { "exists": false, "isDirectory": false, "isGitRepository": false }, "modelConfigured": true, "availableModels": [] }); assert!(validate_submission_preflight(&missing_workspace, None).is_err()); let missing_model = json!({ - "workspace": { "exists": true, "isDirectory": true }, + "workspace": { "exists": true, "isDirectory": true, "isGitRepository": true }, "modelConfigured": false, "modelDiagnostic": "configure a model", "availableModels": [] @@ -929,4 +1174,117 @@ mod tests { }; assert!(same_target_identity(&before, &renamed)); } + + #[tokio::test] + async fn finish_sync_checks_the_recorded_branch_before_reading_the_bundle() { + let temp = tempfile::tempdir().expect("tempdir"); + let repository = temp.path().join("baseline"); + std::fs::create_dir_all(&repository).expect("repository"); + let init = std::process::Command::new("git") + .arg("-C") + .arg(&repository) + .args(["init", "--quiet", "--initial-branch=main"]) + .output() + .expect("run git init"); + assert!( + init.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + + let store = + OutboundDispatchStore::new_in_root_for_tests(temp.path().join("dispatch-outbound")); + let mut record = OutboundDispatchRecord::new( + "job-branch-guard".to_string(), + DispatchTarget::Local, + "session-1".to_string(), + "/target".to_string(), + "prompt", + "succeeded", + ) + .expect("record"); + record.baseline_worktree_path = Some(repository.to_string_lossy().to_string()); + record.branch = Some("bitfun/dispatch/job-branch-guard".to_string()); + + let error = finish_sync( + &store, + &record, + json!({"changed": true}), + &temp.path().join("missing.bundle"), + ) + .await + .expect_err("wrong branch must stop sync before bundle inspection"); + + assert!( + error.to_string().contains("baseline is on branch 'main'"), + "unexpected error: {error}" + ); + } + + #[tokio::test] + async fn compatible_reprobe_recovers_a_durable_started_install_audit_once() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = OutboundDispatchStore::new_in_root_for_tests(temp.path().to_path_buf()); + store + .begin_preparation(DispatchPreparationRequest { + job_id: "job-install-recovery".to_string(), + session_id: "session-1".to_string(), + target: DispatchPreparationTarget::ssh("server-1"), + source_workspace_path: "/repo/linked".to_string(), + project_workspace_path: "/repo/main".to_string(), + }) + .await + .expect("begin preparation"); + store + .append_preparation_setup_audit( + "job-install-recovery", + "attempt-1:1", + json!({ + "timestamp": "2026-07-31T00:00:00Z", + "action": "cli-install", + "details": { + "stage": "cli-install-started", + "release": { "version": "1.2.3" }, + }, + }), + ) + .await + .expect("started audit"); + let probe = DispatchSshProbe { + cli_installed: true, + cli_path: Some("/home/user/.bitfun/bin/bitfun".to_string()), + os: "Linux".to_string(), + arch: "x86_64".to_string(), + install_supported: true, + install_error: None, + protocol_error: None, + release: None, + protocol: Some(json!({ "cliVersion": "1.2.3" })), + prebuilt_incompatible: None, + source_build: None, + }; + + recover_interrupted_cli_install_audit(&store, "job-install-recovery", &probe) + .await + .expect("recover audit"); + recover_interrupted_cli_install_audit(&store, "job-install-recovery", &probe) + .await + .expect("idempotent recovery"); + + let events = store + .preparation_setup_audit("job-install-recovery") + .await + .expect("load audit"); + assert_eq!(events.len(), 2); + assert_eq!( + events[1].pointer("/details/stage").and_then(Value::as_str), + Some("cli-install-succeeded") + ); + assert_eq!( + events[1] + .pointer("/details/release/recovered") + .and_then(Value::as_bool), + Some(true) + ); + } } diff --git a/src/crates/assembly/core/src/service/dispatch/device_controller.rs b/src/crates/assembly/core/src/service/dispatch/device_controller.rs index acfae98b14..1f19b59229 100644 --- a/src/crates/assembly/core/src/service/dispatch/device_controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/device_controller.rs @@ -1,34 +1,66 @@ -use std::path::Path; - use anyhow::{anyhow, Context}; use async_trait::async_trait; use base64::Engine as _; -use bitfun_services_core::dispatch_workspace::sha256_bytes; use bitfun_services_integrations::remote_ssh::dispatch_ssh::{ self, harden_result_directory, DispatchSshProbe, }; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use crate::service::worktree::WorktreeService; + +use super::baseline::{ + build_base_bundle, prepare_baseline, release_prepared_baseline, PreparedBaseline, +}; use super::controller::{ - same_target_identity, validate_answer_request, validate_append_request, - validate_submission_preflight, validate_submit_ack, validate_submit_request, - DispatchAnswerRequest, DispatchAppendRequest, DispatchJobRequest, DispatchListJobsRequest, - DispatchProbeTargetRequest, DispatchStatusRequest, DispatchSubmitRequest, - DISPATCH_PROTOCOL_VERSION, + bind_outbound_record, finish_sync, provisioned_path, release_unbound_preparation_baseline, + result_bundle_path, same_target_identity, target_have_tips, validate_answer_request, + validate_append_request, validate_submission_preflight, validate_submit_ack, + validate_submit_request, DispatchAnswerRequest, DispatchAppendRequest, DispatchJobRequest, + DispatchListJobsRequest, DispatchProbeTargetRequest, DispatchStatusRequest, + DispatchSubmitRequest, DispatchSyncResultRequest, DISPATCH_PROTOCOL_VERSION, }; +use super::preparation::{DispatchPreparationRequest, DispatchPreparationTarget}; use super::{ - adopt_target_jobs, DispatchTarget, DispatchTargetRequest, DispatchWorkspaceDeliveryRequest, - DispatchWorkspaceSnapshotCaptureMode, OutboundDispatchRecord, OutboundDispatchStore, + adopt_target_jobs, DispatchTarget, DispatchTargetRequest, OutboundDispatchRecord, + OutboundDispatchStore, }; const DEVICE_WORKSPACE_CHUNK_BYTES: usize = 256 * 1024; -/// A result bundle carries only changed files, and the device transport -/// reassembles it in memory, so it is bounded well below a full snapshot. +const DEVICE_WORKSPACE_OPERATION_WAIT: std::time::Duration = + std::time::Duration::from_secs(30 * 60); +const DEVICE_WORKSPACE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(750); +/// A result bundle carries only the commits made during the job and is streamed +/// into a private staging file, but it is still bounded to limit retained disk +/// usage from an untrusted peer. const MAX_DEVICE_RESULT_BUNDLE_BYTES: u64 = 256 * 1024 * 1024; -const DEVICE_WORKSPACE_COMMIT_POLL_INTERVAL: std::time::Duration = - std::time::Duration::from_millis(750); -const DEVICE_WORKSPACE_COMMIT_WAIT: std::time::Duration = std::time::Duration::from_secs(15 * 60); + +struct UnverifiedResultBundle { + path: std::path::PathBuf, + verified: bool, +} + +impl UnverifiedResultBundle { + fn new(path: std::path::PathBuf) -> Self { + Self { + path, + verified: false, + } + } + + fn retain(&mut self) { + self.verified = true; + } +} + +impl Drop for UnverifiedResultBundle { + fn drop(&mut self) { + if !self.verified { + let _ = std::fs::remove_file(&self.path); + } + } +} /// Account-device routing is a platform adapter. The product controller owns /// dispatch semantics while Desktop supplies the encrypted Relay RPC. @@ -96,7 +128,9 @@ pub async fn submit_device( validate_submit_request(&request)?; let DispatchTargetRequest::Device { device_id, - workspace_path: requested_workspace_path, + // The target path is the target's business now: dispatch always checks + // out its own worktree there rather than reusing a directory. + workspace_path: _, } = &request.target else { anyhow::bail!("Device dispatch submission requires a device target"); @@ -105,45 +139,130 @@ pub async fn submit_device( anyhow::bail!("Device dispatch requires a deviceId"); } - let workspace_path = resolve_device_workspace( - rpc, + // A device cannot be upgraded by this controller. Check protocol support + // before creating a baseline so an old/offline peer cannot strand a + // controller-side worktree claim. + let initial_protocol = rpc + .invoke(device_id, "dispatch_target_probe", json!({})) + .await + .context("probe device dispatch protocol before baseline creation")?; + dispatch_ssh::validate_dispatch_protocol(&initial_protocol, Some(&request.approval_policy))?; + + let source_workspace_path = request + .source_workspace_path + .as_deref() + .unwrap_or_default() + .trim(); + let project_workspace_path = + WorktreeService::resolve_project_workspace_path(source_workspace_path) + .await + .map_err(|error| anyhow!("resolve the dispatch project workspace: {error}"))?; + let _preparation_run_lock = store.acquire_preparation_run_lock(&request.job_id).await?; + if let Some(existing) = store.get(&request.job_id).await? { + if existing.session_id != request.session_id + || !matches!( + &existing.target, + DispatchTarget::Device { + device_id: existing_device, + .. + } if existing_device == device_id + ) + { + anyhow::bail!("Dispatch jobId is already bound to another target or session"); + } + } + store + .begin_preparation(DispatchPreparationRequest { + job_id: request.job_id.clone(), + session_id: request.session_id.clone(), + target: DispatchPreparationTarget::device(device_id.clone()), + source_workspace_path: source_workspace_path.to_string(), + project_workspace_path, + }) + .await?; + + let baseline = prepare_baseline( store, - device_id, - requested_workspace_path, - &request.workspace_delivery, &request.job_id, + source_workspace_path, + request.base_ref.as_deref(), + request.include_uncommitted, ) .await?; - let protocol = rpc + if let Err(error) = store + .attach_preparation_baseline( + &request.job_id, + &baseline.delivery.baseline_worktree_id, + &baseline.delivery.branch, + ) + .await + { + release_prepared_baseline(store, &request.job_id, &baseline).await; + return Err(error); + } + store.touch_preparation(&request.job_id).await?; + let workspace_path = + match provision_device_workspace(rpc, store, device_id, &request.job_id, &baseline).await { + Ok(path) => path, + Err(error) => { + release_unbound_preparation_baseline(store, &request.job_id, &baseline).await; + return Err(error); + } + }; + store.touch_preparation(&request.job_id).await?; + let protocol = match rpc .invoke( device_id, "dispatch_target_probe", json!({ "workspacePath": workspace_path }), ) .await - .context("probe device immediately before dispatch submission")?; - dispatch_ssh::validate_dispatch_protocol(&protocol, Some(&request.approval_policy))?; - validate_submission_preflight(&protocol, request.model.as_deref())?; - let workspace_path = protocol + .context("probe device immediately before dispatch submission") + { + Ok(protocol) => protocol, + Err(error) => { + release_unbound_preparation_baseline(store, &request.job_id, &baseline).await; + return Err(error); + } + }; + if let Err(error) = + dispatch_ssh::validate_dispatch_protocol(&protocol, Some(&request.approval_policy)) + .and_then(|_| validate_submission_preflight(&protocol, request.model.as_deref())) + { + release_unbound_preparation_baseline(store, &request.job_id, &baseline).await; + return Err(error); + } + let workspace_path = match protocol .pointer("/workspace/path") .and_then(Value::as_str) .filter(|path| !path.trim().is_empty()) - .ok_or_else(|| anyhow!("Device dispatch target returned no canonical workspace path"))? - .to_string(); + { + Some(path) => path.to_string(), + None => { + release_unbound_preparation_baseline(store, &request.job_id, &baseline).await; + anyhow::bail!("Device dispatch target returned no canonical workspace path"); + } + }; let resolved_target = DispatchTarget::Device { device_id: device_id.clone(), workspace_path: workspace_path.clone(), display_name, }; - let requested_record = OutboundDispatchRecord::new( + let requested_record = match OutboundDispatchRecord::new( request.job_id.clone(), resolved_target, request.session_id.clone(), workspace_path.clone(), &request.prompt, "submitting", - )? + ) { + Ok(record) => record, + Err(error) => { + release_unbound_preparation_baseline(store, &request.job_id, &baseline).await; + return Err(error.into()); + } + } .with_submission_metadata( request.title.clone(), request.agent_type.clone(), @@ -153,13 +272,17 @@ pub async fn submit_device( .with_source_workspace( request.source_workspace_path.clone(), request.source_workspace_id.clone(), - ); - let bound_record = store.bind_if_absent(&requested_record).await?; + ) + .with_baseline(&baseline.delivery, &baseline.worktree_path); + let bound_record = bind_outbound_record(store, &requested_record, &baseline).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"); } + store + .mark_preparation_outbound_bound(&requested_record.job_id) + .await?; let mut payload = json!({ "protocolVersion": DISPATCH_PROTOCOL_VERSION, @@ -207,6 +330,16 @@ pub async fn submit_device( store .update_progress(&requested_record.job_id, 0, state) .await?; + if let Err(error) = store + .acknowledge_preparation(&requested_record.job_id) + .await + { + log::warn!( + "Failed to remove acknowledged device dispatch preparation: job_id={} error={}", + requested_record.job_id, + error + ); + } Ok(response) } @@ -226,6 +359,13 @@ pub async fn status_device( json!({ "jobId": request.job_id, "cursor": request.cursor }), ) .await?; + if let Err(error) = store.acknowledge_preparation(&record.job_id).await { + log::warn!( + "Failed to remove device dispatch preparation after status confirmation: job_id={} error={}", + record.job_id, + error + ); + } let state = response .get("state") .and_then(Value::as_str) @@ -234,7 +374,6 @@ pub async fn status_device( store .update_progress(&record.job_id, request.cursor, state) .await?; - let _ = store.remove_workspace_snapshot(&record.job_id).await; Ok(response) } @@ -327,187 +466,101 @@ pub async fn list_device_jobs( Ok(response) } -async fn resolve_device_workspace( +/// Check out the baseline commit on an account device. +/// +/// Same two-phase contract as SSH — provision, and only ship objects when the +/// device says it cannot reach the commit. The device transport carries JSON +/// only, so a bundle travels as base64 chunks inside the existing encrypted +/// envelope rather than over a file channel. +async fn provision_device_workspace( rpc: &dyn DeviceDispatchRpc, store: &OutboundDispatchStore, device_id: &str, - requested_workspace_path: &str, - delivery: &DispatchWorkspaceDeliveryRequest, job_id: &str, + baseline: &PreparedBaseline, ) -> anyhow::Result { - match delivery { - DispatchWorkspaceDeliveryRequest::Existing => { - let path = requested_workspace_path.trim(); - if path.is_empty() { - anyhow::bail!("existing device dispatch requires a workspacePath"); - } - Ok(path.to_string()) - } - DispatchWorkspaceDeliveryRequest::SnapshotSource { - source_workspace_path, - } => { - let prepared = store - .prepare_workspace_snapshot( - job_id, - source_workspace_path, - DispatchWorkspaceSnapshotCaptureMode::Source, - ) - .await?; - upload_device_workspace( - rpc, - device_id, - job_id, - &prepared.archive_path, - &prepared.metadata, - ) - .await - } - DispatchWorkspaceDeliveryRequest::SnapshotExact { - source_workspace_path, - sensitive_files_confirmed, - } => { - if !sensitive_files_confirmed { - anyhow::bail!( - "exact workspace snapshot requires confirmation that ignored and sensitive files may be transferred" - ); - } - let prepared = store - .prepare_workspace_snapshot( - job_id, - source_workspace_path, - DispatchWorkspaceSnapshotCaptureMode::Exact, - ) - .await?; - upload_device_workspace( - rpc, - device_id, - job_id, - &prepared.archive_path, - &prepared.metadata, - ) - .await - } - } -} - -/// Pull a finished job's result bundle back from an account device. -/// -/// The device transport carries JSON only, so the bundle streams back in -/// base64 chunks — the mirror of `upload_device_workspace`. The digest the -/// target reported is verified over the reassembled bytes before anything is -/// staged, so a truncated or altered stream cannot reach the apply step. -pub async fn pull_device_result( - rpc: &dyn DeviceDispatchRpc, - store: &OutboundDispatchStore, - request: DispatchJobRequest, -) -> anyhow::Result { - let destination = super::controller::result_bundle_path(store, &request.job_id); - let destination = destination.as_path(); - let record = store - .get(&request.job_id) - .await? - .ok_or_else(|| anyhow!("Outbound dispatch job was not found"))?; - let DispatchTarget::Device { device_id, .. } = &record.target else { - anyhow::bail!("Device dispatch result pull requires a device target"); - }; + let request = json!({ + "protocolVersion": DISPATCH_PROTOCOL_VERSION, + "jobId": job_id, + "repoKey": baseline.repo_key, + "remoteUrl": baseline.delivery.remote_url, + "baseCommit": baseline.delivery.base_commit, + "branch": baseline.delivery.branch, + }); - let response = rpc - .invoke( - device_id, - "dispatch_target_workspace_result", - json!({ "jobId": request.job_id }), - ) - .await?; - let summary = response - .get("summary") - .ok_or_else(|| anyhow!("Device dispatch target returned no result summary"))?; - let expected_size = summary - .get("archiveSize") - .and_then(Value::as_u64) - .ok_or_else(|| anyhow!("Device dispatch target returned no result bundle size"))?; - let expected_digest = summary - .get("archiveSha256") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("Device dispatch target returned no result bundle digest"))? - .to_string(); - if expected_size > MAX_DEVICE_RESULT_BUNDLE_BYTES { + let response = invoke_device_workspace_operation( + rpc, + device_id, + "dispatch_target_workspace_provision", + request.clone(), + "Git workspace provisioning", + ) + .await?; + if let Some(path) = provisioned_path(&response) { + return Ok(path); + } + if response.get("needsBundle").and_then(Value::as_bool) != Some(true) { anyhow::bail!( - "Device dispatch result bundle exceeds the {} MB safety limit", - MAX_DEVICE_RESULT_BUNDLE_BYTES / (1024 * 1024) + "Device dispatch target neither provisioned a workspace nor asked for a bundle" ); } - let mut bytes = Vec::with_capacity(expected_size as usize); - while (bytes.len() as u64) < expected_size { - let chunk = rpc - .invoke( - device_id, - "dispatch_target_workspace_result_chunk", - json!({ - "jobId": request.job_id, - "offset": bytes.len() as u64, - "length": DEVICE_WORKSPACE_CHUNK_BYTES as u64, - }), - ) - .await?; - let encoded = chunk - .get("dataBase64") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("Device dispatch target returned no result chunk data"))?; - let decoded = base64::engine::general_purpose::STANDARD - .decode(encoded) - .context("decode dispatch result chunk")?; - if decoded.is_empty() { + let bundle = build_base_bundle(store, baseline, &target_have_tips(&response)).await?; + let upload = upload_device_bundle(rpc, device_id, job_id, &bundle).await; + let _ = std::fs::remove_file(&bundle.path); + upload?; + + let response = invoke_device_workspace_operation( + rpc, + device_id, + "dispatch_target_workspace_provision", + request, + "Git workspace provisioning", + ) + .await?; + provisioned_path(&response).ok_or_else(|| { + anyhow!("Device dispatch target could not check out the base commit after the bundle") + }) +} + +async fn invoke_device_workspace_operation( + rpc: &dyn DeviceDispatchRpc, + device_id: &str, + command: &str, + args: Value, + operation: &str, +) -> anyhow::Result { + let deadline = tokio::time::Instant::now() + DEVICE_WORKSPACE_OPERATION_WAIT; + loop { + let response = rpc.invoke(device_id, command, args.clone()).await?; + if response.get("pending").and_then(Value::as_bool) != Some(true) { + return Ok(response); + } + if tokio::time::Instant::now() >= deadline { anyhow::bail!( - "Device dispatch result bundle ended at {} of {expected_size} bytes", - bytes.len() + "{operation} did not finish within {} minutes", + DEVICE_WORKSPACE_OPERATION_WAIT.as_secs() / 60 ); } - bytes.extend_from_slice(&decoded); - if bytes.len() as u64 > expected_size { - anyhow::bail!("Device dispatch target returned more result bytes than it declared"); - } - } - - let actual_digest = sha256_bytes(&bytes); - if !actual_digest.eq_ignore_ascii_case(&expected_digest) { - anyhow::bail!("Device dispatch result bundle does not match the reported digest"); - } - - if let Some(parent) = destination.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("create result staging {}", parent.display()))?; - harden_result_directory(parent)?; - } - dispatch_ssh::write_private_file(destination, &bytes)?; - - let mut response = response; - if let Some(object) = response.as_object_mut() { - object.insert( - "localBundlePath".to_string(), - Value::String(destination.to_string_lossy().to_string()), - ); + tokio::time::sleep(DEVICE_WORKSPACE_POLL_INTERVAL).await; } - // Same durable summary the SSH path records, so applying is transport-blind. - super::controller::record_result_summary(store, &request.job_id, &response)?; - Ok(response) } -async fn upload_device_workspace( +async fn upload_device_bundle( rpc: &dyn DeviceDispatchRpc, device_id: &str, job_id: &str, - archive_path: &Path, - metadata: &bitfun_services_core::dispatch_workspace::WorkspaceSnapshotMetadata, -) -> anyhow::Result { + bundle: &super::baseline::PreparedBundle, +) -> anyhow::Result<()> { let begin = rpc .invoke( device_id, - "dispatch_target_workspace_begin", + "dispatch_target_workspace_bundle_begin", json!({ "protocolVersion": DISPATCH_PROTOCOL_VERSION, "jobId": job_id, - "metadata": metadata, + "sha256": bundle.sha256, + "size": bundle.size, }), ) .await?; @@ -516,39 +569,36 @@ async fn upload_device_workspace( .and_then(Value::as_bool) .unwrap_or(false) { - return required_workspace_path(&begin); + return Ok(()); } if begin.get("accepted").and_then(Value::as_bool) != Some(true) { - anyhow::bail!("Device dispatch target did not accept the workspace snapshot"); + anyhow::bail!("Device dispatch target did not accept the bundle upload"); } let mut offset = begin .get("offset") .and_then(Value::as_u64) - .ok_or_else(|| anyhow!("Device dispatch target returned no workspace upload offset"))?; - if offset > metadata.archive_size { - anyhow::bail!("Device dispatch target returned an invalid workspace upload offset"); + .ok_or_else(|| anyhow!("Device dispatch target returned no bundle upload offset"))?; + if offset > bundle.size { + anyhow::bail!("Device dispatch target returned an invalid bundle upload offset"); } - let mut archive = tokio::fs::File::open(archive_path) + let mut file = tokio::fs::File::open(&bundle.path) .await - .with_context(|| format!("open workspace snapshot {}", archive_path.display()))?; - archive.seek(std::io::SeekFrom::Start(offset)).await?; + .with_context(|| format!("open dispatch bundle {}", bundle.path.display()))?; + file.seek(std::io::SeekFrom::Start(offset)).await?; let mut buffer = vec![0_u8; DEVICE_WORKSPACE_CHUNK_BYTES]; - while offset < metadata.archive_size { - let remaining = (metadata.archive_size - offset) as usize; + while offset < bundle.size { + let remaining = (bundle.size - offset) as usize; let read_limit = remaining.min(buffer.len()); - let read = archive.read(&mut buffer[..read_limit]).await?; + let read = file.read(&mut buffer[..read_limit]).await?; if read == 0 { - anyhow::bail!( - "Workspace snapshot ended at {offset} of {} bytes", - metadata.archive_size - ); + anyhow::bail!("Dispatch bundle ended at {offset} of {} bytes", bundle.size); } let next_offset = offset + read as u64; let response = rpc .invoke( device_id, - "dispatch_target_workspace_chunk", + "dispatch_target_workspace_bundle_chunk", json!({ "jobId": job_id, "offset": offset, @@ -560,47 +610,163 @@ async fn upload_device_workspace( || response.get("offset").and_then(Value::as_u64) != Some(next_offset) { anyhow::bail!( - "Device dispatch target returned a mismatched workspace chunk acknowledgement" + "Device dispatch target returned a mismatched bundle chunk acknowledgement" ); } offset = next_offset; } - let deadline = tokio::time::Instant::now() + DEVICE_WORKSPACE_COMMIT_WAIT; - loop { - let committed = rpc + let committed = invoke_device_workspace_operation( + rpc, + device_id, + "dispatch_target_workspace_bundle_commit", + json!({ "jobId": job_id }), + "Git bundle import", + ) + .await?; + if committed.get("committed").and_then(Value::as_bool) != Some(true) { + anyhow::bail!("Device dispatch target did not commit the delivered bundle"); + } + Ok(()) +} + +/// Sync a finished job's work back from an account device. +/// +/// The device transport carries JSON only, so the bundle streams back in +/// base64 chunks — the mirror of `upload_device_bundle`. The digest the target +/// reported is verified over the reassembled bytes before anything is fetched +/// into the user's repository. +pub async fn sync_device_result( + rpc: &dyn DeviceDispatchRpc, + store: &OutboundDispatchStore, + request: DispatchSyncResultRequest, +) -> anyhow::Result { + let record = load_device_record(store, &request.job_id).await?; + let DispatchTarget::Device { device_id, .. } = &record.target else { + unreachable!("load_device_record validates target kind") + }; + + // Reuse this identity across the operation poll loop, but generate a new + // one for every later user-requested sync. This is what makes a completed + // no-op distinguishable from a fresh check at the same known head. + let mut args = json!({ + "jobId": request.job_id, + "operationId": uuid::Uuid::new_v4().as_simple().to_string(), + }); + if let Some(message) = request + .message + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + args["message"] = Value::String(message.to_string()); + } + if let Some(head) = record.synced_head_commit.as_deref() { + args["knownHead"] = Value::String(head.to_string()); + } + let response = invoke_device_workspace_operation( + rpc, + device_id, + "dispatch_target_workspace_sync", + args, + "Git workspace sync", + ) + .await?; + if response.get("changed").and_then(Value::as_bool) != Some(true) { + return finish_sync(store, &record, response, std::path::Path::new("")).await; + } + + let expected_size = response + .get("bundleSize") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("Device dispatch target returned no result bundle size"))?; + let expected_digest = response + .get("bundleSha256") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("Device dispatch target returned no result bundle digest"))? + .to_string(); + if expected_size == 0 || expected_size > MAX_DEVICE_RESULT_BUNDLE_BYTES { + anyhow::bail!( + "Device dispatch result bundle exceeds the {} MB safety limit", + MAX_DEVICE_RESULT_BUNDLE_BYTES / (1024 * 1024) + ); + } + + let destination = result_bundle_path(store, &request.job_id).await?; + let mut staged_bundle = UnverifiedResultBundle::new(destination.clone()); + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create result staging {}", parent.display()))?; + harden_result_directory(parent)?; + } + dispatch_ssh::write_private_file(&destination, &[])?; + let mut output = std::fs::OpenOptions::new() + .append(true) + .open(&destination) + .with_context(|| format!("open result staging {}", destination.display()))?; + let mut digest = Sha256::new(); + let mut received = 0_u64; + while received < expected_size { + let chunk = rpc .invoke( device_id, - "dispatch_target_workspace_commit", - json!({ "jobId": job_id }), + "dispatch_target_workspace_sync_chunk", + json!({ + "jobId": request.job_id, + "offset": received, + "length": DEVICE_WORKSPACE_CHUNK_BYTES as u64, + }), ) .await?; - if committed - .pointer("/metadata/archiveSha256") + let encoded = chunk + .get("dataBase64") .and_then(Value::as_str) - != Some(metadata.archive_sha256.as_str()) - { - anyhow::bail!("Device dispatch target returned mismatched workspace snapshot metadata"); - } - if committed.get("committed").and_then(Value::as_bool) == Some(true) { - return required_workspace_path(&committed); + .ok_or_else(|| anyhow!("Device dispatch target returned no result chunk data"))?; + if encoded.len() > 384 * 1024 { + anyhow::bail!("Device dispatch target returned an oversized result chunk"); } - if tokio::time::Instant::now() >= deadline { + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .context("decode dispatch result chunk")?; + if decoded.is_empty() || decoded.len() > DEVICE_WORKSPACE_CHUNK_BYTES { anyhow::bail!( - "Device dispatch target workspace materialization did not finish within 15 minutes" + "Device dispatch result bundle ended at {} of {expected_size} bytes", + received ); } - tokio::time::sleep(DEVICE_WORKSPACE_COMMIT_POLL_INTERVAL).await; + let next_offset = received.saturating_add(decoded.len() as u64); + if next_offset > expected_size { + anyhow::bail!("Device dispatch target returned more result bytes than it declared"); + } + if chunk.get("offset").and_then(Value::as_u64) != Some(next_offset) { + anyhow::bail!("Device dispatch target returned a mismatched result chunk offset"); + } + std::io::Write::write_all(&mut output, &decoded) + .with_context(|| format!("write result staging {}", destination.display()))?; + digest.update(&decoded); + received = next_offset; + let eof = chunk.get("eof").and_then(Value::as_bool) == Some(true); + if eof != (received == expected_size) { + anyhow::bail!("Device dispatch target returned an inconsistent result end marker"); + } } -} + output + .sync_all() + .with_context(|| format!("flush result staging {}", destination.display()))?; + let actual_digest = format!("{:x}", digest.finalize()); + if !actual_digest.eq_ignore_ascii_case(&expected_digest) { + anyhow::bail!("Device dispatch result bundle does not match the reported digest"); + } + staged_bundle.retain(); -fn required_workspace_path(response: &Value) -> anyhow::Result { - response - .get("workspacePath") - .and_then(Value::as_str) - .filter(|path| !path.trim().is_empty()) - .map(ToOwned::to_owned) - .ok_or_else(|| anyhow!("Device dispatch target returned no materialized workspace path")) + let mut response = response; + if let Some(object) = response.as_object_mut() { + object.insert( + "localBundlePath".to_string(), + Value::String(destination.to_string_lossy().to_string()), + ); + } + finish_sync(store, &record, response, &destination).await } async fn load_device_record( @@ -620,6 +786,7 @@ async fn load_device_record( #[cfg(test)] mod tests { use super::*; + use bitfun_services_core::dispatch_workspace::sha256_bytes; use std::sync::Mutex; #[test] @@ -632,11 +799,12 @@ mod tests { "dispatch_target_list", "dispatch_target_answer", "dispatch_target_append", - "dispatch_target_workspace_begin", - "dispatch_target_workspace_chunk", - "dispatch_target_workspace_commit", - "dispatch_target_workspace_result", - "dispatch_target_workspace_result_chunk", + "dispatch_target_workspace_provision", + "dispatch_target_workspace_bundle_begin", + "dispatch_target_workspace_bundle_chunk", + "dispatch_target_workspace_bundle_commit", + "dispatch_target_workspace_sync", + "dispatch_target_workspace_sync_chunk", ] { assert!(command.starts_with("dispatch_target_")); assert_ne!(command, "dispatch_submit"); @@ -660,19 +828,25 @@ mod tests { ) -> anyhow::Result { self.calls.lock().unwrap().push(command.to_string()); match command { - "dispatch_target_workspace_result" => Ok(json!({ - "bundlePath": "/home/u/.bitfun/dispatch/workspaces/job-1/result.tar.gz", - "workspacePath": "/home/u/.bitfun/dispatch/workspaces/job-1/current", - "summary": { - "added": ["new.txt"], - "modified": [], - "deleted": [], - "baselineSha256": {}, - "archiveSize": self.bundle.len() as u64, - "archiveSha256": self.declared_digest, - } - })), - "dispatch_target_workspace_result_chunk" => { + "dispatch_target_workspace_sync" => { + assert!(args + .get("operationId") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty())); + Ok(json!({ + "changed": true, + "branch": "bitfun/dispatch/job-1", + "baseCommit": "0".repeat(40), + "headCommit": "1".repeat(40), + "commitCount": 1, + "changes": [{ "status": "A", "path": "new.txt" }], + "truncatedChanges": false, + "bundlePath": "/home/u/.bitfun/dispatch/workspaces/job-1/result.bundle", + "bundleSha256": self.declared_digest, + "bundleSize": self.bundle.len() as u64, + })) + } + "dispatch_target_workspace_sync_chunk" => { let offset = args.get("offset").and_then(Value::as_u64).unwrap() as usize; let length = args.get("length").and_then(Value::as_u64).unwrap() as usize; let end = (offset + length).min(self.bundle.len()); @@ -688,7 +862,7 @@ mod tests { } } - async fn device_store(root: &Path) -> OutboundDispatchStore { + async fn device_store(root: &std::path::Path) -> OutboundDispatchStore { let store = OutboundDispatchStore::new_in_root_for_tests(root.to_path_buf()); let record = OutboundDispatchRecord::new( "job-1".to_string(), @@ -719,35 +893,35 @@ mod tests { calls: Mutex::new(Vec::new()), }; - let response = pull_device_result( + // No baseline worktree is recorded, so the fetch half must refuse. The + // download half still has to have run and verified the bytes first — + // that is what this asserts. + let error = sync_device_result( &rpc, &store, - DispatchJobRequest { + DispatchSyncResultRequest { job_id: "job-1".to_string(), + message: None, }, ) .await - .expect("pull"); + .expect_err("a record without a baseline cannot be synced"); + assert!(error.to_string().contains("baseline worktree")); - let staged = response - .get("localBundlePath") - .and_then(Value::as_str) - .expect("staged path"); - assert_eq!(std::fs::read(staged).expect("read staged"), bundle); + let staged = temp.path().join(".results/job-1.bundle"); + assert_eq!(std::fs::read(&staged).expect("read staged"), bundle); let chunk_calls = rpc .calls .lock() .unwrap() .iter() - .filter(|c| c.as_str() == "dispatch_target_workspace_result_chunk") + .filter(|c| c.as_str() == "dispatch_target_workspace_sync_chunk") .count(); assert!(chunk_calls >= 2, "a multi-chunk bundle must loop"); - // The summary must be recorded for the apply step, as on the SSH path. - assert!(temp.path().join(".results/job-1.json").is_file()); } #[tokio::test] - async fn a_tampered_device_stream_never_reaches_the_apply_step() { + async fn a_tampered_device_stream_never_reaches_the_repository() { let temp = tempfile::tempdir().expect("temp"); let store = device_store(temp.path()).await; let bundle = vec![3_u8; 4096]; @@ -758,24 +932,23 @@ mod tests { calls: Mutex::new(Vec::new()), }; - let error = pull_device_result( + let error = sync_device_result( &rpc, &store, - DispatchJobRequest { + DispatchSyncResultRequest { job_id: "job-1".to_string(), + message: None, }, ) .await - .expect_err("a digest mismatch must fail the pull"); - assert!( - error - .to_string() - .contains("does not match the reported digest"), - "{error}" - ); + .expect_err("a tampered stream must fail"); + + assert!(error + .to_string() + .contains("does not match the reported digest")); assert!( - !temp.path().join(".results/job-1.tar.gz").exists(), - "nothing may be staged when the stream does not verify" + !temp.path().join(".results/job-1.bundle").exists(), + "nothing may be staged when the digest does not match" ); } } diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs index b8faf80404..a4acb602aa 100644 --- a/src/crates/assembly/core/src/service/dispatch/mod.rs +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -1,64 +1,57 @@ #[cfg(feature = "ssh-remote")] +mod baseline; +#[cfg(feature = "ssh-remote")] mod controller; #[cfg(feature = "ssh-remote")] mod device_controller; +#[cfg(feature = "ssh-remote")] +mod preparation; mod target; use std::path::{Path, PathBuf}; use anyhow::Context as _; -use bitfun_services_core::dispatch_workspace::{ - exact_workspace_matches_manifest, exact_workspace_snapshot_source_fingerprint, - prepare_exact_workspace_snapshot, prepare_source_workspace_snapshot, sha256_file, - source_workspace_matches_manifest, source_workspace_snapshot_source_fingerprint, - WorkspaceSnapshotManifest, WorkspaceSnapshotMetadata, WorkspaceSnapshotSourceFingerprint, -}; use bitfun_services_core::json_store::{JsonFileStore, JsonFileStoreError}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use sha2::{Digest, Sha256}; use thiserror::Error; use tokio::fs; use crate::infrastructure::PathManager; -/// Result-bundle shapes the desktop layer returns to the renderer. -#[cfg(feature = "ssh-remote")] -pub use bitfun_services_core::dispatch_workspace::{ - WorkspaceResultApplyOutcome, WorkspaceResultConflict, WorkspaceResultConflictReason, - WorkspaceResultSummary, -}; #[cfg(feature = "ssh-remote")] pub use controller::{ - answer as answer_dispatch, append as append_dispatch, apply_result as apply_dispatch_result, - cancel as cancel_dispatch, install_cli_cancel as cancel_dispatch_cli_install, + answer as answer_dispatch, append as append_dispatch, cancel as cancel_dispatch, + install_cli_cancel as cancel_dispatch_cli_install, install_cli_poll as poll_dispatch_cli_install, install_cli_source_start as start_dispatch_cli_source_build, 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, - pull_result as pull_dispatch_result, status as get_dispatch_status, submit as submit_dispatch, - sync_model_config as sync_dispatch_model_config, DispatchAnswerRequest, DispatchAppendRequest, - DispatchApplyResultRequest, DispatchConnectionRequest, DispatchInstallPollRequest, - DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, - DispatchListTargetsRequest, DispatchPermissionReplyKind, DispatchProbeTargetRequest, - DispatchStatusRequest, DispatchSubmitRequest, DispatchTargetOption, + status as get_dispatch_status, submit as submit_dispatch, + sync_model_config as sync_dispatch_model_config, sync_result as sync_dispatch_result, + DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, + DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, + DispatchListJobsRequest, DispatchListTargetsRequest, DispatchPermissionReplyKind, + DispatchProbeTargetRequest, DispatchStatusRequest, DispatchSubmitRequest, + DispatchSyncResultRequest, DispatchTargetOption, }; #[cfg(feature = "ssh-remote")] pub use device_controller::{ answer_device as answer_device_dispatch, append_device as append_device_dispatch, cancel_device as cancel_device_dispatch, list_device_jobs as list_device_dispatch_jobs, - probe_device as probe_device_dispatch_target, - pull_device_result as pull_device_dispatch_result, status_device as get_device_dispatch_status, - submit_device as submit_device_dispatch, DeviceDispatchRpc, + probe_device as probe_device_dispatch_target, status_device as get_device_dispatch_status, + submit_device as submit_device_dispatch, sync_device_result as sync_device_dispatch_result, + DeviceDispatchRpc, }; -pub use target::{DispatchTarget, DispatchTargetRequest, DispatchWorkspaceDeliveryRequest}; +pub use target::{DispatchTarget, DispatchTargetRequest, DispatchWorkspaceDelivery}; const PROMPT_PREVIEW_CHARS: usize = 160; -const OUTBOUND_WORKSPACE_UPLOADS_DIR: &str = ".workspace-uploads"; -const OUTBOUND_WORKSPACE_CACHE_DIR: &str = ".workspace-cache"; -/// Where pulled result bundles are staged before the user applies them. +/// Where synced result bundles are staged before they are fetched into the +/// controller's baseline worktree. pub(super) const OUTBOUND_RESULTS_DIR: &str = ".results"; +/// Where base bundles are built before being uploaded to a target. +const OUTBOUND_BUNDLES_DIR: &str = ".bundles"; /// Where the renderer's observer transcript cache lives. const OUTBOUND_TRANSCRIPTS_DIR: &str = ".transcripts"; const TERMINAL_OUTBOUND_RETENTION_DAYS: i64 = 30; @@ -69,51 +62,6 @@ const TERMINAL_OUTBOUND_RETENTION_DAYS: i64 = 30; /// is exactly the behavior that existed before the cache. const MAX_OUTBOUND_TRANSCRIPT_BYTES: usize = 8 * 1024 * 1024; -#[derive(Debug, Clone)] -pub struct PreparedOutboundWorkspaceSnapshot { - pub archive_path: PathBuf, - pub metadata: WorkspaceSnapshotMetadata, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct OutboundWorkspaceSnapshotRecord { - source_workspace_path: String, - #[serde(default)] - capture_mode: DispatchWorkspaceSnapshotCaptureMode, - metadata: WorkspaceSnapshotMetadata, - #[serde(default, skip_serializing_if = "Option::is_none")] - source_fingerprint: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct OutboundWorkspaceCacheRecord { - source_workspace_path: String, - capture_mode: DispatchWorkspaceSnapshotCaptureMode, - source_fingerprint: WorkspaceSnapshotSourceFingerprint, - metadata: WorkspaceSnapshotMetadata, - created_at: DateTime, - last_used_at: DateTime, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum DispatchWorkspaceSnapshotCaptureMode { - Source, - #[default] - Exact, -} - -impl DispatchWorkspaceSnapshotCaptureMode { - fn cache_key_label(self) -> &'static [u8] { - match self { - Self::Source => b"source", - Self::Exact => b"exact", - } - } -} - #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct DispatchTargetJobEntry { @@ -153,6 +101,31 @@ pub struct OutboundDispatchRecord { pub approval_policy: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, + /// Managed worktree on this controller that this job was branched from. + /// + /// Recorded so sync-back knows where to fetch the target's branch into, and + /// so cleanup can release the worktree's retention claim. A record without + /// it predates Git-worktree delivery and can only be observed, not synced. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub baseline_worktree_id: Option, + /// Stable main-project path that owns the baseline's worktree registry. + /// Unlike `source_workspace_path`, this does not point at a linked + /// worktree that may disappear before retention cleanup runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub baseline_project_workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub baseline_worktree_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_url: Option, + /// Tip of `branch` the last successful sync fetched. + /// + /// Lets the UI tell "never synced" from "synced and unchanged since". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub synced_head_commit: Option, pub last_cursor: u64, pub last_state: String, pub created_at: DateTime, @@ -182,6 +155,13 @@ impl OutboundDispatchRecord { agent_type: None, approval_policy: None, model: None, + baseline_worktree_id: None, + baseline_project_workspace_path: None, + baseline_worktree_path: None, + base_commit: None, + branch: None, + remote_url: None, + synced_head_commit: None, last_cursor: 0, last_state: state.into(), created_at: now, @@ -212,6 +192,29 @@ impl OutboundDispatchRecord { self.source_workspace_id = source_workspace_id.filter(|value| !value.trim().is_empty()); self } + + /// Record the Git baseline this job was branched from. + /// + /// Written before the target is contacted, so a submit whose response is + /// lost still leaves a record that names the worktree holding its claim. + pub fn with_baseline( + mut self, + delivery: &DispatchWorkspaceDelivery, + worktree_path: &str, + ) -> Self { + self.baseline_worktree_id = non_empty(&delivery.baseline_worktree_id); + self.baseline_project_workspace_path = non_empty(&delivery.project_workspace_path); + self.baseline_worktree_path = non_empty(worktree_path); + self.base_commit = non_empty(&delivery.base_commit); + self.branch = non_empty(&delivery.branch); + self.remote_url = delivery.remote_url.as_deref().and_then(non_empty); + self + } +} + +fn non_empty(value: &str) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) } #[derive(Debug, Clone, Deserialize)] @@ -241,6 +244,8 @@ pub enum DispatchStoreError { Io(#[from] std::io::Error), #[error("Failed to persist outbound dispatch index: {0}")] Json(#[from] JsonFileStoreError), + #[error("Failed to release outbound dispatch baseline claim: {0}")] + ClaimRelease(String), } /// Durable observer-only index for jobs submitted to other BitFun processes. @@ -343,6 +348,10 @@ impl OutboundDispatchStore { } pub async fn list(&self) -> Result, DispatchStoreError> { + #[cfg(feature = "ssh-remote")] + if let Err(error) = self.reconcile_expired_preparations().await { + log::warn!("Failed to reconcile expired dispatch preparations: {error}"); + } 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()), @@ -368,37 +377,35 @@ impl OutboundDispatchStore { .num_days() >= TERMINAL_OUTBOUND_RETENTION_DAYS => { - // Best effort: a stranded bundle is disk waste, not a - // correctness problem, and must not keep the expired record - // alive forever. - if let Err(error) = self.remove_result_bundle(&record.job_id).await { - log::warn!( - "Failed to remove expired dispatch result bundle: job_id={} error={}", - record.job_id, - error - ); - } - if let Err(error) = self.remove_transcript(&record.job_id).await { - log::warn!( - "Failed to remove expired dispatch observer transcript: job_id={} error={}", - record.job_id, - error - ); - } - if let Err(error) = self.remove_workspace_snapshot(&record.job_id).await { - log::warn!( - "Failed to remove expired outbound dispatch snapshot: job_id={} error={}", - record.job_id, - error - ); - records.push(record); - } else if let Err(error) = self.remove(&record.job_id).await { - log::warn!( - "Failed to remove expired outbound dispatch record: job_id={} error={}", - record.job_id, - error - ); - records.push(record); + match self.remove(&record.job_id).await { + Ok(_) => { + // Result/transcript artifacts are disposable only + // after claim release and durable-record deletion + // succeed. A failed claim release keeps the whole + // cleanup token intact for the next retry. + if let Err(error) = self.remove_result_bundle(&record.job_id).await { + log::warn!( + "Failed to remove expired dispatch result bundle: job_id={} error={}", + record.job_id, + error + ); + } + if let Err(error) = self.remove_transcript(&record.job_id).await { + log::warn!( + "Failed to remove expired dispatch observer transcript: job_id={} error={}", + record.job_id, + error + ); + } + } + Err(error) => { + log::warn!( + "Failed to remove expired outbound dispatch record: job_id={} error={}", + record.job_id, + error + ); + records.push(record); + } } } Ok(Some(record)) => records.push(record), @@ -422,273 +429,89 @@ impl OutboundDispatchStore { } 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()), - } + self.remove_with_claim_releaser(job_id, release_baseline_claim) + .await } - /// Build or reopen the immutable snapshot bound to one outbound job. - /// - /// Keeping the verified artifact after an ambiguous submit is essential: - /// an idempotent retry must not capture a newer local tree and conflict - /// with the snapshot that the target may already have committed. - pub async fn prepare_workspace_snapshot( + async fn remove_with_claim_releaser( &self, job_id: &str, - source_workspace_path: &str, - capture_mode: DispatchWorkspaceSnapshotCaptureMode, - ) -> anyhow::Result { - validate_id(job_id)?; - let source = std::path::PathBuf::from(source_workspace_path.trim()); - if !source.is_absolute() { - anyhow::bail!("snapshot sourceWorkspacePath must be absolute"); - } - let source = tokio::task::spawn_blocking(move || source.canonicalize()) - .await - .map_err(|error| anyhow::anyhow!("snapshot path task failed: {error}"))? - .map_err(|error| anyhow::anyhow!("resolve snapshot source: {error}"))?; - if !source.is_dir() { - anyhow::bail!("snapshot source is not a directory"); - } - let source_wire = source - .to_str() - .map(ToOwned::to_owned) - .ok_or_else(|| anyhow::anyhow!("snapshot source path is not valid UTF-8"))?; - let uploads = self.root.join(OUTBOUND_WORKSPACE_UPLOADS_DIR); - fs::create_dir_all(&uploads).await?; - harden_directory_permissions(&uploads).await?; - let uploads = tokio::task::spawn_blocking(move || uploads.canonicalize()) - .await - .map_err(|error| anyhow::anyhow!("snapshot staging path task failed: {error}"))? - .map_err(|error| anyhow::anyhow!("resolve snapshot staging directory: {error}"))?; - if uploads.starts_with(&source) { - anyhow::bail!( - "snapshot source cannot contain the controller dispatch staging directory" - ); - } - let cache = self.root.join(OUTBOUND_WORKSPACE_CACHE_DIR); - fs::create_dir_all(&cache).await?; - harden_directory_permissions(&cache).await?; - let cache = tokio::task::spawn_blocking(move || cache.canonicalize()) - .await - .map_err(|error| anyhow::anyhow!("snapshot cache path task failed: {error}"))? - .map_err(|error| anyhow::anyhow!("resolve snapshot cache directory: {error}"))?; - if cache.starts_with(&source) { - anyhow::bail!("snapshot source cannot contain the controller snapshot cache"); - } - let record_path = uploads.join(format!("{job_id}.json")); - let archive_path = uploads.join(format!("{job_id}.tar.gz")); - let _lock = self - .json_store - .acquire_cross_process_lock(&record_path) - .await?; - - if let Some(record) = self + release_claim: Release, + ) -> Result + where + Release: FnOnce(BaselineClaimRelease) -> ReleaseFuture, + ReleaseFuture: std::future::Future>, + { + let path = self.record_path(job_id)?; + let _lock = self.json_store.acquire_cross_process_lock(&path).await?; + let Some(record) = self .json_store - .read_optional::(&record_path) + .read_optional::(&path) .await? - { - if record.source_workspace_path != source_wire || record.capture_mode != capture_mode { - anyhow::bail!("dispatch jobId is already bound to another workspace snapshot"); - } - let valid = - snapshot_archive_is_valid(archive_path.clone(), record.metadata.clone()).await?; - if valid { - return Ok(PreparedOutboundWorkspaceSnapshot { - archive_path, - metadata: record.metadata, - }); - } - let _ = fs::remove_file(&record_path).await; - let _ = fs::remove_file(&archive_path).await; - } else { - let _ = fs::remove_file(&archive_path).await; - } - - let cache_key = outbound_workspace_cache_key(&source_wire, capture_mode); - let cache_record_path = cache.join(format!("{cache_key}.json")); - let cache_archive_path = cache.join(format!("{cache_key}.tar.gz")); - let _cache_lock = self - .json_store - .acquire_cross_process_lock(&cache_record_path) - .await?; - let mut cached = match self - .json_store - .read_optional::(&cache_record_path) - .await - { - Ok(record) => record, - Err(error) => { - log::warn!( - "Ignoring unreadable outbound workspace cache record: path={} error={}", - cache_record_path.display(), - error - ); - None - } + else { + return Ok(false); }; - if cached.as_ref().is_some_and(|record| { - record.source_workspace_path != source_wire || record.capture_mode != capture_mode - }) { - log::warn!( - "Ignoring outbound workspace cache identity mismatch: path={}", - cache_record_path.display() - ); - cached = None; + + // The durable record is the retry token for claim cleanup. Keep both it + // and its cross-process lock until cleanup succeeds; deleting first + // would make a transient registry/path failure strand the claim forever. + if let Some(release) = BaselineClaimRelease::for_record(&record) { + release_claim(release).await?; } - let cache_manifest_path = cache.join(format!("{cache_key}.manifest.json")); - let decision_started_at = std::time::Instant::now(); - if let Some(mut cached) = cached { - let current_fingerprint = - workspace_source_fingerprint(source.clone(), capture_mode).await?; - // The fingerprint is metadata-only, so it also reports a change for - // content-neutral operations such as chmod, a git checkout round - // trip, or an editor's write-then-rename. Ask the per-file manifest - // for a second opinion before paying for a full repack and a full - // retransfer to the target. - let fingerprint_matched = current_fingerprint == cached.source_fingerprint; - let reusable = if fingerprint_matched { - true - } else { - match self - .cached_workspace_manifest(&cache_manifest_path, &cached.metadata) - .await - { - // A cache written before this sidecar existed, or one whose - // sidecar no longer belongs to the cached archive, simply - // degrades to the previous full-repack behavior. - None => false, - Some(manifest) => { - workspace_matches_manifest(source.clone(), capture_mode, manifest).await? - } - } - }; - if reusable - && snapshot_archive_is_valid(cache_archive_path.clone(), cached.metadata.clone()) - .await? - { - replace_snapshot_archive(&cache_archive_path, &archive_path).await?; - // Adopt the current fingerprint so the next dispatch takes the - // cheap path instead of rereading the tree every time. - cached.source_fingerprint = current_fingerprint; - cached.last_used_at = Utc::now(); - self.json_store - .write_atomic_strict(&cache_record_path, &cached) - .await?; - harden_file_permissions(&cache_record_path).await?; - let record = OutboundWorkspaceSnapshotRecord { - source_workspace_path: source_wire, - capture_mode, - metadata: cached.metadata.clone(), - source_fingerprint: Some(cached.source_fingerprint), - }; - self.json_store - .write_atomic_strict(&record_path, &record) - .await?; - harden_file_permissions(&record_path).await?; - // Reported so the cost of each decision layer is observable - // before deciding whether per-file delta transfer is worth its - // protocol change: a "content" reuse is the layer this cache - // gained, and its elapsed time is what that layer costs. - log::info!( - "Reused cached dispatch workspace snapshot: job_id={job_id} mode={capture_mode:?} matched_by={} bytes={} elapsed_ms={}", - if fingerprint_matched { - "metadata" - } else { - "content" - }, - cached.metadata.archive_size, - decision_started_at.elapsed().as_millis() - ); - return Ok(PreparedOutboundWorkspaceSnapshot { - archive_path, - metadata: cached.metadata, - }); - } + match fs::remove_file(&path).await { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => return Err(error.into()), } + } - remove_file_if_present(&cache_record_path).await?; - remove_file_if_present(&cache_archive_path).await?; - remove_file_if_present(&cache_manifest_path).await?; - let package_source = source.clone(); - let package_archive = archive_path.clone(); - let prepared = tokio::task::spawn_blocking(move || match capture_mode { - DispatchWorkspaceSnapshotCaptureMode::Source => { - prepare_source_workspace_snapshot(&package_source, &package_archive) - } - DispatchWorkspaceSnapshotCaptureMode::Exact => { - prepare_exact_workspace_snapshot(&package_source, &package_archive) - } - }) - .await - .map_err(|error| anyhow::anyhow!("snapshot packaging task failed: {error}"))??; - // The counterpart of the reuse log. These two lines together are what - // says how often a repack is genuinely earned and how many bytes a - // delta would have saved. - log::info!( - "Repacked dispatch workspace snapshot: job_id={job_id} mode={capture_mode:?} files={} bytes={} elapsed_ms={}", - prepared.metadata.file_count, - prepared.metadata.archive_size, - decision_started_at.elapsed().as_millis() - ); - harden_file_permissions(&archive_path).await?; - publish_snapshot_cache_archive( - &archive_path, - &cache_archive_path, - &cache, - &cache_key, - job_id, - ) - .await?; - // Written before the record so a reader can never observe a cache - // record that claims a manifest sidecar which is not there yet. - self.json_store - .write_atomic(&cache_manifest_path, &prepared.manifest) - .await?; - harden_file_permissions(&cache_manifest_path).await?; - let now = Utc::now(); - let cache_record = OutboundWorkspaceCacheRecord { - source_workspace_path: source_wire.clone(), - capture_mode, - source_fingerprint: prepared.source_fingerprint.clone(), - metadata: prepared.metadata.clone(), - created_at: now, - last_used_at: now, - }; - self.json_store - .write_atomic_strict(&cache_record_path, &cache_record) - .await?; - harden_file_permissions(&cache_record_path).await?; - let record = OutboundWorkspaceSnapshotRecord { - source_workspace_path: source_wire, - capture_mode, - metadata: prepared.metadata.clone(), - source_fingerprint: Some(prepared.source_fingerprint), + /// Record the branch tip a successful sync fetched into the baseline. + pub async fn record_synced_head(&self, job_id: &str, head: &str) -> anyhow::Result<()> { + let path = self.record_path(job_id)?; + let _lock = self.json_store.acquire_cross_process_lock(&path).await?; + let Some(mut record) = self + .json_store + .read_optional::(&path) + .await? + else { + return Ok(()); }; - self.json_store - .write_atomic_strict(&record_path, &record) - .await?; - harden_file_permissions(&record_path).await?; - Ok(PreparedOutboundWorkspaceSnapshot { - archive_path, - metadata: prepared.metadata, - }) + record.synced_head_commit = non_empty(head); + record.updated_at = Utc::now(); + self.json_store.write_atomic(&path, &record).await?; + harden_file_permissions(&path).await?; + Ok(()) + } + + /// Owner-only staging directory for outbound Git bundles. + /// + /// Bundles hold repository contents, so they get the same private treatment + /// as everything else the controller writes here. + pub(crate) async fn bundles_dir(&self) -> anyhow::Result { + let bundles = self.root.join(OUTBOUND_BUNDLES_DIR); + fs::create_dir_all(&bundles).await?; + harden_directory_permissions(&bundles).await?; + Ok(bundles) } - /// Drop a pulled result bundle and its summary. + /// Owner-only staging directory for bundles fetched back from a target. + pub(crate) async fn results_dir(&self) -> anyhow::Result { + let results = self.root.join(OUTBOUND_RESULTS_DIR); + fs::create_dir_all(&results).await?; + harden_directory_permissions(&results).await?; + Ok(results) + } + + /// Drop a synced result bundle and its summary. /// - /// Separate from `remove_workspace_snapshot` on purpose: that one runs as - /// soon as the target durably owns the job, which is long before the user - /// has had a chance to look at the results. + /// The bundle is only a transfer artifact: once it has been fetched into + /// the baseline worktree the objects live in the repository, so deleting it + /// never loses work. pub async fn remove_result_bundle(&self, job_id: &str) -> anyhow::Result<()> { validate_id(job_id)?; let results = self.root.join(OUTBOUND_RESULTS_DIR); for path in [ - results.join(format!("{job_id}.tar.gz")), + results.join(format!("{job_id}.bundle")), results.join(format!("{job_id}.json")), ] { match fs::remove_file(&path).await { @@ -759,68 +582,6 @@ impl OutboundDispatchStore { .join(format!("{job_id}.json"))) } - pub async fn remove_workspace_snapshot(&self, job_id: &str) -> anyhow::Result<()> { - validate_id(job_id)?; - let uploads = self.root.join(OUTBOUND_WORKSPACE_UPLOADS_DIR); - let record_path = uploads.join(format!("{job_id}.json")); - let archive_path = uploads.join(format!("{job_id}.tar.gz")); - let _lock = self - .json_store - .acquire_cross_process_lock(&record_path) - .await?; - for path in [record_path, archive_path] { - match fs::remove_file(&path).await { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(error.into()), - } - } - Ok(()) - } - - /// Load the per-file manifest that belongs to a cached snapshot archive. - /// - /// The sidecar is only trusted when it re-encodes to the manifest digest the - /// cached archive was sealed with. That binding is what makes it safe to - /// hand the archive to a target after comparing against this manifest - /// instead of repacking. Anything unreadable, unparseable, or mismatched - /// yields `None`, which degrades to a full repack. - async fn cached_workspace_manifest( - &self, - manifest_path: &Path, - expected: &WorkspaceSnapshotMetadata, - ) -> Option { - let manifest = match self - .json_store - .read_optional::(manifest_path) - .await - { - Ok(Some(manifest)) => manifest, - Ok(None) => return None, - Err(error) => { - log::warn!( - "Ignoring unreadable outbound workspace manifest: path={} error={}", - manifest_path.display(), - error - ); - return None; - } - }; - let encoded = serde_json::to_vec(&manifest).ok()?; - let mut digest = Sha256::new(); - digest.update(&encoded); - let digest = format!("{:x}", digest.finalize()); - if !digest.eq_ignore_ascii_case(&expected.manifest_sha256) { - log::warn!( - "Ignoring outbound workspace manifest that does not match its cached archive: \ - path={}", - manifest_path.display() - ); - return None; - } - Some(manifest) - } - fn record_path(&self, job_id: &str) -> Result { validate_id(job_id)?; Ok(self.root.join(format!("{job_id}.json"))) @@ -833,108 +594,57 @@ impl OutboundDispatchStore { } } -fn outbound_workspace_cache_key( - source_workspace_path: &str, - capture_mode: DispatchWorkspaceSnapshotCaptureMode, -) -> String { - let mut digest = Sha256::new(); - digest.update(b"bitfun-dispatch-outbound-workspace-cache"); - digest.update(capture_mode.cache_key_label()); - digest.update((source_workspace_path.len() as u64).to_le_bytes()); - digest.update(source_workspace_path.as_bytes()); - format!("{:x}", digest.finalize()) +#[derive(Debug, Clone, PartialEq, Eq)] +struct BaselineClaimRelease { + job_id: String, + project_workspace_path: String, + worktree_id: String, + claimed_by: String, } -async fn workspace_source_fingerprint( - source: PathBuf, - capture_mode: DispatchWorkspaceSnapshotCaptureMode, -) -> anyhow::Result { - tokio::task::spawn_blocking(move || match capture_mode { - DispatchWorkspaceSnapshotCaptureMode::Source => { - source_workspace_snapshot_source_fingerprint(&source) - } - DispatchWorkspaceSnapshotCaptureMode::Exact => { - exact_workspace_snapshot_source_fingerprint(&source) - } - }) - .await - .map_err(|error| anyhow::anyhow!("snapshot fingerprint task failed: {error}"))? +impl BaselineClaimRelease { + fn for_record(record: &OutboundDispatchRecord) -> Option { + let project_workspace_path = record + .baseline_project_workspace_path + .as_deref() + .or(record.source_workspace_path.as_deref()) + .map(str::trim) + .filter(|path| !path.is_empty())?; + let worktree_id = record + .baseline_worktree_id + .as_deref() + .map(str::trim) + .filter(|worktree_id| !worktree_id.is_empty())?; + Some(Self { + job_id: record.job_id.clone(), + project_workspace_path: project_workspace_path.to_string(), + worktree_id: worktree_id.to_string(), + claimed_by: baseline_claim(&record.job_id), + }) + } } -async fn workspace_matches_manifest( - source: PathBuf, - capture_mode: DispatchWorkspaceSnapshotCaptureMode, - manifest: WorkspaceSnapshotManifest, -) -> anyhow::Result { - tokio::task::spawn_blocking(move || match capture_mode { - DispatchWorkspaceSnapshotCaptureMode::Source => { - source_workspace_matches_manifest(&source, &manifest) - } - DispatchWorkspaceSnapshotCaptureMode::Exact => { - exact_workspace_matches_manifest(&source, &manifest) - } - }) +/// Release the worktree retention claim an outbound record was holding. +/// +/// The caller intentionally keeps the durable outbound record until this +/// succeeds, so a moved repository or temporary registry error remains +/// observable and retryable instead of silently stranding a claim. +async fn release_baseline_claim(release: BaselineClaimRelease) -> Result<(), DispatchStoreError> { + crate::service::worktree::WorktreeService::release_claim_for_worktree( + &release.project_workspace_path, + &release.worktree_id, + &release.claimed_by, + ) .await - .map_err(|error| anyhow::anyhow!("snapshot manifest comparison task failed: {error}"))? -} - -async fn snapshot_archive_is_valid( - archive: PathBuf, - expected: WorkspaceSnapshotMetadata, -) -> anyhow::Result { - tokio::task::spawn_blocking(move || -> anyhow::Result { - let metadata = match std::fs::symlink_metadata(&archive) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => return Err(error.into()), - }; - if metadata.file_type().is_symlink() - || !metadata.is_file() - || metadata.len() != expected.archive_size - { - return Ok(false); - } - Ok(sha256_file(&archive)?.eq_ignore_ascii_case(&expected.archive_sha256)) + .map(|_| ()) + .map_err(|error| { + DispatchStoreError::ClaimRelease(format!("job_id={} error={error}", release.job_id)) }) - .await - .map_err(|error| anyhow::anyhow!("snapshot verification task failed: {error}"))? } -async fn replace_snapshot_archive(source: &Path, destination: &Path) -> anyhow::Result<()> { - remove_file_if_present(destination).await?; - if let Err(link_error) = fs::hard_link(source, destination).await { - if let Err(copy_error) = fs::copy(source, destination).await { - let _ = fs::remove_file(destination).await; - anyhow::bail!( - "copy snapshot archive {} to {} after hard-link failed ({link_error}): \ - {copy_error}", - source.display(), - destination.display() - ); - } - } - harden_file_permissions(destination).await?; - Ok(()) -} - -async fn publish_snapshot_cache_archive( - source: &Path, - destination: &Path, - cache_directory: &Path, - cache_key: &str, - job_id: &str, -) -> anyhow::Result<()> { - let staging = cache_directory.join(format!(".{cache_key}.{job_id}.tmp")); - remove_file_if_present(&staging).await?; - replace_snapshot_archive(source, &staging).await?; - remove_file_if_present(destination).await?; - if let Err(error) = fs::rename(&staging, destination).await { - let _ = fs::remove_file(&staging).await; - return Err(error) - .with_context(|| format!("publish snapshot cache archive {}", destination.display())); - } - harden_file_permissions(destination).await?; - Ok(()) +/// Claim string a dispatch job holds on its baseline worktree. +pub fn baseline_claim(job_id: &str) -> String { + format!("dispatch:{job_id}") } async fn remove_file_if_present(path: &Path) -> anyhow::Result<()> { @@ -1218,18 +928,136 @@ mod tests { } } + #[tokio::test] + async fn removing_an_outbound_record_releases_its_baseline_claim_once() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = OutboundDispatchStore::new_in_root_for_tests(temp.path().to_path_buf()); + let mut record = OutboundDispatchRecord::new( + "job-1".to_string(), + target(), + "session-1".to_string(), + "/srv/app".to_string(), + "Summarize the repository", + "succeeded", + ) + .expect("record") + .with_source_workspace(Some("/linked/repo".to_string()), None); + record.baseline_worktree_id = Some("wt-baseline".to_string()); + record.baseline_project_workspace_path = Some("/stable/repo".to_string()); + store.bind_if_absent(&record).await.expect("persist"); + + let releases = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let captured = std::sync::Arc::clone(&releases); + let callback_store = store.clone(); + assert!(store + .remove_with_claim_releaser("job-1", move |release| { + assert!( + callback_store.root().join("job-1.json").is_file(), + "the durable record must remain until claim release succeeds" + ); + let captured = std::sync::Arc::clone(&captured); + async move { + captured.lock().expect("release capture").push(release); + Ok(()) + } + }) + .await + .expect("remove record")); + assert_eq!( + releases.lock().expect("releases").as_slice(), + &[BaselineClaimRelease { + job_id: "job-1".to_string(), + project_workspace_path: "/stable/repo".to_string(), + worktree_id: "wt-baseline".to_string(), + claimed_by: "dispatch:job-1".to_string(), + }] + ); + assert!( + store + .get("job-1") + .await + .expect("read removed record") + .is_none(), + "the record is deleted only after claim release succeeds" + ); + + let called_again = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let called = std::sync::Arc::clone(&called_again); + assert!(!store + .remove_with_claim_releaser("job-1", move |_| { + let called = std::sync::Arc::clone(&called); + async move { + called.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + }) + .await + .expect("idempotent remove")); + assert!( + !called_again.load(std::sync::atomic::Ordering::SeqCst), + "an absent record has no claim to release" + ); + } + + #[tokio::test] + async fn failed_claim_release_keeps_the_outbound_record_retryable() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = OutboundDispatchStore::new_in_root_for_tests(temp.path().to_path_buf()); + let mut record = OutboundDispatchRecord::new( + "job-claim-retry".to_string(), + target(), + "session-1".to_string(), + "/srv/app".to_string(), + "Summarize the repository", + "succeeded", + ) + .expect("record") + .with_source_workspace(Some("/linked/repo".to_string()), None); + record.baseline_worktree_id = Some("wt-baseline".to_string()); + record.baseline_project_workspace_path = Some("/stable/repo".to_string()); + store.bind_if_absent(&record).await.expect("persist"); + + let error = store + .remove_with_claim_releaser("job-claim-retry", |_| async { + Err(DispatchStoreError::ClaimRelease( + "temporary registry failure".to_string(), + )) + }) + .await + .expect_err("claim failure must stop record deletion"); + assert!(matches!(error, DispatchStoreError::ClaimRelease(_))); + assert!( + store + .get("job-claim-retry") + .await + .expect("read retained record") + .is_some(), + "the durable record is the retry token for claim cleanup" + ); + + assert!(store + .remove_with_claim_releaser("job-claim-retry", |_| async { Ok(()) }) + .await + .expect("retry removal")); + assert!(store + .get("job-claim-retry") + .await + .expect("read removed record") + .is_none()); + } + #[tokio::test] async fn expired_jobs_do_not_strand_their_result_bundles() { let temp = tempfile::tempdir().expect("temp dir"); let store = OutboundDispatchStore::new_in_root_for_tests(temp.path().to_path_buf()); let results = temp.path().join(OUTBOUND_RESULTS_DIR); fs::create_dir_all(&results).await.expect("results dir"); - let bundle = results.join("job-1.tar.gz"); + let bundle = results.join("job-1.bundle"); let summary = results.join("job-1.json"); fs::write(&bundle, b"bundle").await.expect("bundle"); fs::write(&summary, b"{}").await.expect("summary"); // A second job's bundle must survive the first job's cleanup. - let other = results.join("job-2.tar.gz"); + let other = results.join("job-2.bundle"); fs::write(&other, b"other").await.expect("other"); store.remove_result_bundle("job-1").await.expect("remove"); @@ -1431,297 +1259,6 @@ mod tests { ); } - #[tokio::test] - async fn workspace_cache_reuses_unchanged_source_across_dispatch_jobs() { - let temp = tempfile::tempdir().expect("temp dir"); - let root = temp.path().join("outbound"); - let store = OutboundDispatchStore::new_in_root_for_tests(root.clone()); - let source = temp.path().join("workspace"); - std::fs::create_dir_all(source.join(".git")).expect("repository marker"); - std::fs::create_dir_all(source.join("target")).expect("ignored directory"); - std::fs::write(source.join(".gitignore"), b"target/\n").expect("gitignore"); - std::fs::write(source.join("main.rs"), b"fn main() {}").expect("source"); - std::fs::write(source.join("target/app"), b"first build").expect("ignored output"); - let source_wire = source - .canonicalize() - .expect("canonical source") - .to_string_lossy() - .to_string(); - let cache_key = outbound_workspace_cache_key( - &source_wire, - DispatchWorkspaceSnapshotCaptureMode::Source, - ); - let cache_archive = root - .join(OUTBOUND_WORKSPACE_CACHE_DIR) - .join(format!("{cache_key}.tar.gz")); - - let first = store - .prepare_workspace_snapshot( - "job-1", - &source_wire, - DispatchWorkspaceSnapshotCaptureMode::Source, - ) - .await - .expect("first snapshot"); - let first_cache_metadata = std::fs::metadata(&cache_archive).expect("first cached archive"); - store - .remove_workspace_snapshot("job-1") - .await - .expect("remove first job snapshot"); - assert!( - cache_archive.exists(), - "removing a completed job must retain the reusable cache" - ); - - std::fs::write(source.join("target/app"), b"a different ignored build") - .expect("change ignored output"); - let second = store - .prepare_workspace_snapshot( - "job-2", - &source_wire, - DispatchWorkspaceSnapshotCaptureMode::Source, - ) - .await - .expect("cached snapshot"); - let second_cache_metadata = - std::fs::metadata(&cache_archive).expect("reused cached archive"); - assert_eq!(first.metadata, second.metadata); - assert_eq!( - first_cache_metadata.modified().expect("first modified"), - second_cache_metadata.modified().expect("second modified"), - "a cache hit must not recreate the archive" - ); - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - assert_eq!( - second_cache_metadata.ino(), - std::fs::metadata(&second.archive_path) - .expect("job archive") - .ino(), - "each job should hard-link the immutable cached archive" - ); - } - - std::fs::write( - source.join("main.rs"), - b"fn main() { println!(\"changed\"); }", - ) - .expect("change included source"); - let third = store - .prepare_workspace_snapshot( - "job-3", - &source_wire, - DispatchWorkspaceSnapshotCaptureMode::Source, - ) - .await - .expect("invalidated snapshot"); - assert_ne!( - second.metadata.archive_sha256, third.metadata.archive_sha256, - "an included source change must invalidate the cached archive" - ); - } - - /// Fixture shared by the metadata-churn cache tests. - /// - /// Returns the canonical source path, the cache key directory entries, and - /// a store rooted inside the same temp dir. - fn snapshot_cache_fixture( - temp: &tempfile::TempDir, - ) -> (OutboundDispatchStore, PathBuf, String, PathBuf, PathBuf) { - let root = temp.path().join("outbound"); - let store = OutboundDispatchStore::new_in_root_for_tests(root.clone()); - let source = temp.path().join("workspace"); - std::fs::create_dir_all(source.join(".git")).expect("repository marker"); - std::fs::create_dir_all(source.join("src")).expect("source directory"); - std::fs::write(source.join(".gitignore"), b"target/\n").expect("gitignore"); - std::fs::write(source.join("src/main.rs"), b"fn main() {}").expect("source"); - let canonical = source.canonicalize().expect("canonical source"); - let source_wire = canonical.to_string_lossy().to_string(); - let cache_key = outbound_workspace_cache_key( - &source_wire, - DispatchWorkspaceSnapshotCaptureMode::Source, - ); - let cache_dir = root.join(OUTBOUND_WORKSPACE_CACHE_DIR); - ( - store, - canonical, - source_wire, - cache_dir.join(format!("{cache_key}.tar.gz")), - cache_dir.join(format!("{cache_key}.manifest.json")), - ) - } - - /// The source 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. Those must not force a full repack and a full - /// retransfer to the target. - #[cfg(unix)] - #[tokio::test] - async fn workspace_cache_survives_content_neutral_metadata_changes() { - use std::os::unix::fs::{MetadataExt, PermissionsExt}; - - let temp = tempfile::tempdir().expect("temp dir"); - let (store, source, source_wire, cache_archive, cache_manifest) = - snapshot_cache_fixture(&temp); - - let first = store - .prepare_workspace_snapshot( - "job-1", - &source_wire, - DispatchWorkspaceSnapshotCaptureMode::Source, - ) - .await - .expect("first snapshot"); - let first_cache_ino = std::fs::metadata(&cache_archive) - .expect("first cached archive") - .ino(); - assert!( - cache_manifest.exists(), - "packaging must publish the manifest sidecar the comparison relies on" - ); - - // chmod, without touching the executable bit the manifest records. - std::fs::set_permissions( - source.join(".gitignore"), - std::fs::Permissions::from_mode(0o640), - ) - .expect("chmod"); - // Write-then-rename: identical bytes, brand new inode. - let staging = temp.path().join("main.rs.tmp"); - std::fs::write(&staging, b"fn main() {}").expect("staging write"); - std::fs::rename(&staging, source.join("src/main.rs")).expect("rename over source"); - - let second = store - .prepare_workspace_snapshot( - "job-2", - &source_wire, - DispatchWorkspaceSnapshotCaptureMode::Source, - ) - .await - .expect("second snapshot"); - - assert_eq!( - first.metadata, second.metadata, - "content-neutral churn must reuse the cached snapshot" - ); - assert_eq!( - first_cache_ino, - std::fs::metadata(&cache_archive) - .expect("reused cached archive") - .ino(), - "a content match must not repack the cached archive" - ); - - // The adopted fingerprint is what keeps the next dispatch on the cheap - // path instead of rehashing the tree every single time. - let cache_key = outbound_workspace_cache_key( - &source_wire, - DispatchWorkspaceSnapshotCaptureMode::Source, - ); - let cache_record: OutboundWorkspaceCacheRecord = JsonFileStore - .read_optional( - &temp - .path() - .join("outbound") - .join(OUTBOUND_WORKSPACE_CACHE_DIR) - .join(format!("{cache_key}.json")), - ) - .await - .expect("read cache record") - .expect("cache record present"); - assert_eq!( - cache_record.source_fingerprint, - source_workspace_snapshot_source_fingerprint(&source).expect("current fingerprint"), - "a content match must adopt the current fingerprint" - ); - } - - /// The structural pass only compares stat data, so an edit that preserves - /// file size has to be caught by the content pass. - #[tokio::test] - async fn workspace_cache_invalidates_on_same_size_content_change() { - let temp = tempfile::tempdir().expect("temp dir"); - let (store, source, source_wire, _cache_archive, _cache_manifest) = - snapshot_cache_fixture(&temp); - - std::fs::write(source.join("src/config.rs"), b"const N: u8 = 1;").expect("config"); - let first = store - .prepare_workspace_snapshot( - "job-1", - &source_wire, - DispatchWorkspaceSnapshotCaptureMode::Source, - ) - .await - .expect("first snapshot"); - - // Same byte count, different content: only the content pass can see it. - std::fs::write(source.join("src/config.rs"), b"const N: u8 = 2;").expect("same-size edit"); - - let second = store - .prepare_workspace_snapshot( - "job-2", - &source_wire, - DispatchWorkspaceSnapshotCaptureMode::Source, - ) - .await - .expect("second snapshot"); - assert_ne!( - first.metadata.archive_sha256, second.metadata.archive_sha256, - "a same-size content change must still invalidate the cache" - ); - } - - /// A cache written before the manifest sidecar existed, or one whose - /// sidecar no longer matches its archive, must fall back to the previous - /// full-repack behavior rather than reusing an unverified archive. - #[cfg(unix)] - #[tokio::test] - async fn workspace_cache_without_manifest_falls_back_to_repacking() { - use std::os::unix::fs::MetadataExt; - - let temp = tempfile::tempdir().expect("temp dir"); - let (store, source, source_wire, cache_archive, cache_manifest) = - snapshot_cache_fixture(&temp); - - store - .prepare_workspace_snapshot( - "job-1", - &source_wire, - DispatchWorkspaceSnapshotCaptureMode::Source, - ) - .await - .expect("first snapshot"); - let first_cache_ino = std::fs::metadata(&cache_archive) - .expect("first cached archive") - .ino(); - std::fs::remove_file(&cache_manifest).expect("simulate a pre-sidecar cache"); - - let staging = temp.path().join("main.rs.tmp"); - std::fs::write(&staging, b"fn main() {}").expect("staging write"); - std::fs::rename(&staging, source.join("src/main.rs")).expect("rename over source"); - - store - .prepare_workspace_snapshot( - "job-2", - &source_wire, - DispatchWorkspaceSnapshotCaptureMode::Source, - ) - .await - .expect("second snapshot"); - assert_ne!( - first_cache_ino, - std::fs::metadata(&cache_archive) - .expect("republished cached archive") - .ino(), - "a cache with no manifest must repack instead of reusing the archive" - ); - assert!( - cache_manifest.exists(), - "the repack must publish a sidecar so the next dispatch can compare" - ); - } - #[tokio::test] async fn rejects_path_traversal_job_ids() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/crates/assembly/core/src/service/dispatch/preparation.rs b/src/crates/assembly/core/src/service/dispatch/preparation.rs new file mode 100644 index 0000000000..5cdc6d92ee --- /dev/null +++ b/src/crates/assembly/core/src/service/dispatch/preparation.rs @@ -0,0 +1,959 @@ +use std::future::Future; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Result}; +use bitfun_services_core::json_store::JsonFileCrossProcessLock; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::fs; + +use crate::service::worktree::WorktreeService; + +use super::{ + baseline_claim, harden_directory_permissions, harden_file_permissions, validate_id, + DispatchTarget, OutboundDispatchRecord, OutboundDispatchStore, +}; + +const PREPARATIONS_DIR: &str = ".preparations"; +const PREPARATION_SCHEMA_VERSION: u32 = 1; +const PREPARATION_LEASE_HOURS: i64 = 2; +const MAX_SETUP_AUDIT_EVENTS: usize = 32; +const MAX_SETUP_AUDIT_EVENT_BYTES: usize = 32 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub(super) enum DispatchPreparationTarget { + Ssh { + #[serde(rename = "connectionId")] + connection_id: String, + }, + Device { + #[serde(rename = "deviceId")] + device_id: String, + }, +} + +impl DispatchPreparationTarget { + pub(super) fn ssh(connection_id: impl Into) -> Self { + Self::Ssh { + connection_id: connection_id.into(), + } + } + + pub(super) fn device(device_id: impl Into) -> Self { + Self::Device { + device_id: device_id.into(), + } + } + + fn matches_outbound(&self, record: &OutboundDispatchRecord) -> bool { + match (self, &record.target) { + ( + Self::Ssh { connection_id }, + DispatchTarget::Ssh { + connection_id: outbound, + .. + }, + ) => connection_id == outbound, + ( + Self::Device { device_id }, + DispatchTarget::Device { + device_id: outbound, + .. + }, + ) => device_id == outbound, + _ => false, + } + } +} + +#[derive(Debug, Clone)] +pub(super) struct DispatchPreparationRequest { + pub job_id: String, + pub session_id: String, + pub target: DispatchPreparationTarget, + pub source_workspace_path: String, + pub project_workspace_path: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum DispatchPreparationPhase { + Preparing, + OutboundBound, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DispatchPreparationAuditEntry { + event_id: String, + event: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DispatchPreparationRecord { + schema_version: u32, + job_id: String, + session_id: String, + target: DispatchPreparationTarget, + source_workspace_path: String, + project_workspace_path: String, + claimed_by: String, + phase: DispatchPreparationPhase, + #[serde(default, skip_serializing_if = "Option::is_none")] + baseline_worktree_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + branch: Option, + #[serde(default)] + setup_audit: Vec, + lease_expires_at: DateTime, + updated_at: DateTime, +} + +impl DispatchPreparationRecord { + fn new(request: DispatchPreparationRequest, now: DateTime) -> Self { + Self { + schema_version: PREPARATION_SCHEMA_VERSION, + claimed_by: baseline_claim(&request.job_id), + job_id: request.job_id, + session_id: request.session_id, + target: request.target, + source_workspace_path: request.source_workspace_path, + project_workspace_path: request.project_workspace_path, + phase: DispatchPreparationPhase::Preparing, + baseline_worktree_id: None, + branch: None, + setup_audit: Vec::new(), + lease_expires_at: preparation_lease(now), + updated_at: now, + } + } + + fn ensure_identity(&self, request: &DispatchPreparationRequest) -> Result<()> { + if self.schema_version != PREPARATION_SCHEMA_VERSION { + bail!("unsupported dispatch preparation journal schema"); + } + if self.job_id != request.job_id + || self.session_id != request.session_id + || self.target != request.target + || self.source_workspace_path != request.source_workspace_path + || self.project_workspace_path != request.project_workspace_path + || self.claimed_by != baseline_claim(&request.job_id) + { + bail!("dispatch jobId is already bound to a different preparation"); + } + Ok(()) + } + + fn touch(&mut self, now: DateTime) { + self.lease_expires_at = preparation_lease(now); + self.updated_at = now; + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PreparationClaimRelease { + Exact { + project_workspace_path: String, + worktree_id: String, + claimed_by: String, + }, + ByOwner { + project_workspace_path: String, + claimed_by: String, + }, +} + +impl OutboundDispatchStore { + /// Serialize every controller attempt for one immutable job id. The run + /// lock is deliberately separate from the journal JSON lock so journal + /// updates can remain atomic while the long SSH/Git operation is active. + pub(super) async fn acquire_preparation_run_lock( + &self, + job_id: &str, + ) -> Result { + let path = self.preparation_run_path(job_id)?; + self.ensure_preparations_root().await?; + Ok(self.json_store.acquire_cross_process_lock(&path).await?) + } + + /// Bind an attempt before either automatic installation or baseline claim + /// creation. Retries with the same immutable identity reuse the journal. + pub(super) async fn begin_preparation( + &self, + request: DispatchPreparationRequest, + ) -> Result<()> { + validate_preparation_request(&request)?; + let path = self.preparation_path(&request.job_id)?; + self.ensure_preparations_root().await?; + let _lock = self.json_store.acquire_cross_process_lock(&path).await?; + let now = Utc::now(); + let record = match self + .json_store + .read_optional::(&path) + .await? + { + Some(mut existing) => { + existing.ensure_identity(&request)?; + existing.touch(now); + existing + } + None => DispatchPreparationRecord::new(request, now), + }; + self.write_preparation_unlocked(&path, &record).await + } + + pub(super) async fn touch_preparation(&self, job_id: &str) -> Result<()> { + self.update_preparation(job_id, |record| { + record.touch(Utc::now()); + Ok(()) + }) + .await + } + + pub(super) async fn attach_preparation_baseline( + &self, + job_id: &str, + worktree_id: &str, + branch: &str, + ) -> Result<()> { + let worktree_id = required_value("baseline worktree id", worktree_id)?; + let branch = required_value("dispatch branch", branch)?; + self.update_preparation(job_id, move |record| { + if record + .baseline_worktree_id + .as_deref() + .is_some_and(|existing| existing != worktree_id) + || record + .branch + .as_deref() + .is_some_and(|existing| existing != branch) + { + bail!("dispatch preparation is already bound to a different baseline"); + } + record.baseline_worktree_id = Some(worktree_id.to_string()); + record.branch = Some(branch.to_string()); + record.touch(Utc::now()); + Ok(()) + }) + .await + } + + /// Clear a normally released, not-yet-bound baseline while retaining CLI + /// setup audit for a later retry of the same dispatch. + pub(super) async fn clear_preparation_baseline(&self, job_id: &str) -> Result<()> { + self.update_preparation(job_id, |record| { + if record.phase == DispatchPreparationPhase::OutboundBound { + bail!("cannot clear a baseline owned by an outbound dispatch record"); + } + record.baseline_worktree_id = None; + record.branch = None; + record.touch(Utc::now()); + Ok(()) + }) + .await + } + + pub(super) async fn append_preparation_setup_audit( + &self, + job_id: &str, + event_id: &str, + event: Value, + ) -> Result<()> { + let event_id = validate_event_id(event_id)?.to_string(); + validate_setup_audit_event(&event)?; + self.update_preparation(job_id, move |record| { + if let Some(existing) = record + .setup_audit + .iter() + .find(|entry| entry.event_id == event_id) + { + if existing.event != event { + bail!("dispatch setup audit event id was reused with different content"); + } + record.touch(Utc::now()); + return Ok(()); + } + if record.setup_audit.len() >= MAX_SETUP_AUDIT_EVENTS { + bail!("dispatch setup audit exceeds the 32-event safety limit"); + } + record + .setup_audit + .push(DispatchPreparationAuditEntry { event_id, event }); + record.touch(Utc::now()); + Ok(()) + }) + .await + } + + pub(super) async fn preparation_setup_audit(&self, job_id: &str) -> Result> { + let path = self.preparation_path(job_id)?; + let Some(record) = self + .json_store + .read_optional::(&path) + .await? + else { + return Ok(Vec::new()); + }; + validate_preparation_record(&record)?; + Ok(record + .setup_audit + .into_iter() + .map(|entry| entry.event) + .collect()) + } + + pub(super) async fn mark_preparation_outbound_bound(&self, job_id: &str) -> Result<()> { + self.update_preparation(job_id, |record| { + if record.baseline_worktree_id.is_none() || record.branch.is_none() { + bail!("dispatch preparation has no baseline to bind"); + } + record.phase = DispatchPreparationPhase::OutboundBound; + record.touch(Utc::now()); + Ok(()) + }) + .await + } + + /// Remove the journal only after a target response proves its durable job + /// exists. Lost acknowledgements intentionally leave it for an idempotent + /// retry so setup audit cannot disappear. + pub(super) async fn acknowledge_preparation(&self, job_id: &str) -> Result<()> { + let path = self.preparation_path(job_id)?; + let _lock = self.json_store.acquire_cross_process_lock(&path).await?; + match fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + } + + /// Best-effort startup cleanup. A matching outbound owner always wins and + /// an unreadable outbound index fails closed; only a proven orphan loses + /// its worktree retention claim. + pub(super) async fn reconcile_expired_preparations(&self) -> Result { + self.reconcile_expired_preparations_with(Utc::now(), |release| async move { + match release { + PreparationClaimRelease::Exact { + project_workspace_path, + worktree_id, + claimed_by, + } => { + WorktreeService::release_claim_for_worktree( + &project_workspace_path, + &worktree_id, + &claimed_by, + ) + .await + .map_err(|error| anyhow!(error.to_string()))?; + } + PreparationClaimRelease::ByOwner { + project_workspace_path, + claimed_by, + } => { + WorktreeService::release_claim(&project_workspace_path, &claimed_by) + .await + .map_err(|error| anyhow!(error.to_string()))?; + } + } + Ok(()) + }) + .await + } + + async fn reconcile_expired_preparations_with( + &self, + now: DateTime, + mut release_claim: Release, + ) -> Result + where + Release: FnMut(PreparationClaimRelease) -> ReleaseFuture, + ReleaseFuture: Future>, + { + let root = self.preparations_root(); + let mut entries = match fs::read_dir(&root).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error.into()), + }; + let mut removed = 0; + 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; + } + let Some(job_id) = path + .file_stem() + .and_then(|value| value.to_str()) + .map(ToOwned::to_owned) + else { + continue; + }; + if validate_id(&job_id).is_err() { + continue; + } + // Inspect without a lock first so ordinary live (unexpired) + // preparations never make observer startup wait on their run + // lock. Recovery takes locks in the same run -> JSON order as + // submit, then re-reads before acting. + let candidate = match self + .json_store + .read_optional::(&path) + .await + { + Ok(Some(candidate)) => candidate, + Ok(None) => continue, + Err(error) => { + log::warn!( + "Preserving unreadable dispatch preparation journal: job_id={} error={}", + job_id, + error + ); + continue; + } + }; + if candidate.lease_expires_at > now { + continue; + } + let _run_lock = self.acquire_preparation_run_lock(&job_id).await?; + let _lock = self.json_store.acquire_cross_process_lock(&path).await?; + let mut preparation = match self + .json_store + .read_optional::(&path) + .await + { + Ok(Some(preparation)) => preparation, + Ok(None) => continue, + Err(error) => { + log::warn!( + "Preserving unreadable dispatch preparation journal after lock: job_id={} error={}", + job_id, + error + ); + continue; + } + }; + if let Err(error) = validate_preparation_record(&preparation) { + log::warn!( + "Skipping invalid dispatch preparation journal: job_id={} error={}", + job_id, + error + ); + continue; + } + if preparation.lease_expires_at > now { + continue; + } + + let outbound = match self.get(&job_id).await { + Ok(record) => record, + Err(error) => { + log::warn!( + "Preserving dispatch preparation because outbound ownership is unreadable: job_id={} error={}", + job_id, + error + ); + continue; + } + }; + if let Some(record) = outbound.as_ref() { + let exact_owner = preparation + .baseline_worktree_id + .as_deref() + .zip(preparation.branch.as_deref()) + .is_some_and(|(worktree_id, branch)| { + record.baseline_worktree_id.as_deref() == Some(worktree_id) + && record.branch.as_deref() == Some(branch) + }); + // A journal that crashed before attach cannot safely use its + // broad owner release while any durable record exists. + if exact_owner || preparation.baseline_worktree_id.is_none() { + if preparation.target.matches_outbound(record) + && preparation.session_id == record.session_id + { + preparation.phase = DispatchPreparationPhase::OutboundBound; + preparation.touch(now); + self.write_preparation_unlocked(&path, &preparation).await?; + } + continue; + } + } + + let release = match preparation.baseline_worktree_id.as_deref() { + Some(worktree_id) => PreparationClaimRelease::Exact { + project_workspace_path: preparation.project_workspace_path.clone(), + worktree_id: worktree_id.to_string(), + claimed_by: preparation.claimed_by.clone(), + }, + None => PreparationClaimRelease::ByOwner { + project_workspace_path: preparation.project_workspace_path.clone(), + claimed_by: preparation.claimed_by.clone(), + }, + }; + if let Err(error) = release_claim(release).await { + log::warn!( + "Failed to release expired dispatch preparation claim: job_id={} error={}", + job_id, + error + ); + continue; + } + match fs::remove_file(&path).await { + Ok(()) => removed += 1, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(removed) + } + + async fn update_preparation(&self, job_id: &str, update: Update) -> Result<()> + where + Update: FnOnce(&mut DispatchPreparationRecord) -> Result<()>, + { + let path = self.preparation_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(|| anyhow!("dispatch preparation journal was not found"))?; + validate_preparation_record(&record)?; + update(&mut record)?; + self.write_preparation_unlocked(&path, &record).await + } + + async fn write_preparation_unlocked( + &self, + path: &Path, + record: &DispatchPreparationRecord, + ) -> Result<()> { + self.json_store.write_atomic_strict(path, record).await?; + harden_file_permissions(path).await?; + Ok(()) + } + + async fn ensure_preparations_root(&self) -> Result<()> { + let root = self.preparations_root(); + fs::create_dir_all(&root).await?; + harden_directory_permissions(&root).await?; + Ok(()) + } + + fn preparations_root(&self) -> PathBuf { + self.root.join(PREPARATIONS_DIR) + } + + fn preparation_path(&self, job_id: &str) -> Result { + validate_id(job_id)?; + Ok(self.preparations_root().join(format!("{job_id}.json"))) + } + + fn preparation_run_path(&self, job_id: &str) -> Result { + validate_id(job_id)?; + Ok(self.preparations_root().join(format!("{job_id}.run"))) + } +} + +fn validate_preparation_request(request: &DispatchPreparationRequest) -> Result<()> { + validate_id(&request.job_id)?; + required_value("dispatch session id", &request.session_id)?; + required_value("source workspace path", &request.source_workspace_path)?; + required_value("project workspace path", &request.project_workspace_path)?; + match &request.target { + DispatchPreparationTarget::Ssh { connection_id } => { + required_value("SSH connection id", connection_id)?; + } + DispatchPreparationTarget::Device { device_id } => { + required_value("device id", device_id)?; + } + } + Ok(()) +} + +fn validate_preparation_record(record: &DispatchPreparationRecord) -> Result<()> { + if record.schema_version != PREPARATION_SCHEMA_VERSION { + bail!("unsupported dispatch preparation journal schema"); + } + validate_preparation_request(&DispatchPreparationRequest { + job_id: record.job_id.clone(), + session_id: record.session_id.clone(), + target: record.target.clone(), + source_workspace_path: record.source_workspace_path.clone(), + project_workspace_path: record.project_workspace_path.clone(), + })?; + if record.claimed_by != baseline_claim(&record.job_id) { + bail!("dispatch preparation claim owner is invalid"); + } + if record.baseline_worktree_id.is_some() != record.branch.is_some() { + bail!("dispatch preparation baseline is incomplete"); + } + if record.setup_audit.len() > MAX_SETUP_AUDIT_EVENTS { + bail!("dispatch setup audit exceeds the 32-event safety limit"); + } + for entry in &record.setup_audit { + validate_event_id(&entry.event_id)?; + validate_setup_audit_event(&entry.event)?; + } + Ok(()) +} + +fn validate_setup_audit_event(event: &Value) -> Result<()> { + let object = event + .as_object() + .ok_or_else(|| anyhow!("dispatch setup audit event must be an object"))?; + if object.get("action").and_then(Value::as_str) != Some("cli-install") + || object + .get("timestamp") + .and_then(Value::as_str) + .is_none_or(|value| value.trim().is_empty()) + || !object + .get("details") + .is_some_and(|details| details.is_object()) + { + bail!("dispatch setup audit event is invalid"); + } + if serde_json::to_vec(event)?.len() > MAX_SETUP_AUDIT_EVENT_BYTES { + bail!("dispatch setup audit event exceeds the 32 KiB safety limit"); + } + Ok(()) +} + +fn validate_event_id(value: &str) -> Result<&str> { + let value = value.trim(); + if value.is_empty() + || value.len() > 256 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':' | b'.')) + { + bail!("dispatch setup audit event id is invalid"); + } + Ok(value) +} + +fn required_value<'a>(name: &str, value: &'a str) -> Result<&'a str> { + let value = value.trim(); + if value.is_empty() { + bail!("{name} cannot be empty"); + } + Ok(value) +} + +fn preparation_lease(now: DateTime) -> DateTime { + now + Duration::hours(PREPARATION_LEASE_HOURS) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store() -> (tempfile::TempDir, OutboundDispatchStore) { + let temp = tempfile::tempdir().expect("tempdir"); + let store = OutboundDispatchStore::new_in_root_for_tests(temp.path().to_path_buf()); + (temp, store) + } + + fn request(job_id: &str) -> DispatchPreparationRequest { + DispatchPreparationRequest { + job_id: job_id.to_string(), + session_id: "session-1".to_string(), + target: DispatchPreparationTarget::ssh("server-1"), + source_workspace_path: "/repo/linked".to_string(), + project_workspace_path: "/repo/main".to_string(), + } + } + + fn audit(stage: &str) -> Value { + serde_json::json!({ + "timestamp": "2026-07-31T00:00:00Z", + "action": "cli-install", + "details": { "stage": stage, "release": {} }, + }) + } + + #[tokio::test] + async fn preparation_identity_is_immutable_and_audit_is_idempotent() { + let (_temp, store) = store(); + store + .begin_preparation(request("job-1")) + .await + .expect("begin"); + store + .append_preparation_setup_audit("job-1", "attempt-1:1", audit("started")) + .await + .expect("append"); + store + .append_preparation_setup_audit("job-1", "attempt-1:1", audit("started")) + .await + .expect("idempotent append"); + assert_eq!( + store + .preparation_setup_audit("job-1") + .await + .expect("audit") + .len(), + 1 + ); + + let mut conflicting = request("job-1"); + conflicting.session_id = "session-2".to_string(); + assert!(store.begin_preparation(conflicting).await.is_err()); + } + + #[tokio::test] + async fn expired_orphan_releases_once_and_release_failure_is_retryable() { + let (_temp, store) = store(); + store + .begin_preparation(request("job-orphan")) + .await + .expect("begin"); + store + .attach_preparation_baseline("job-orphan", "worktree-1", "bitfun/dispatch/job") + .await + .expect("attach"); + let path = store.preparation_path("job-orphan").expect("path"); + let mut record: DispatchPreparationRecord = store + .json_store + .read_optional(&path) + .await + .expect("read") + .expect("record"); + record.lease_expires_at = Utc::now() - Duration::minutes(1); + store + .write_preparation_unlocked(&path, &record) + .await + .expect("expire"); + + let failed = store + .reconcile_expired_preparations_with(Utc::now(), |_| async { + Err(anyhow!("registry unavailable")) + }) + .await + .expect("failed reconcile is best effort"); + assert_eq!(failed, 0); + assert!(path.exists(), "failed release keeps the retry journal"); + + let releases = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let captured = std::sync::Arc::clone(&releases); + let removed = store + .reconcile_expired_preparations_with(Utc::now(), move |release| { + let captured = std::sync::Arc::clone(&captured); + async move { + captured.lock().expect("capture").push(release); + Ok(()) + } + }) + .await + .expect("retry reconcile"); + assert_eq!(removed, 1); + assert_eq!(releases.lock().expect("releases").len(), 1); + assert!(!path.exists()); + } + + #[tokio::test] + async fn expired_matching_outbound_owner_preserves_the_journal() { + let (_temp, store) = store(); + store + .begin_preparation(request("job-owned")) + .await + .expect("begin"); + store + .attach_preparation_baseline("job-owned", "worktree-1", "bitfun/dispatch/job") + .await + .expect("attach"); + let mut outbound = OutboundDispatchRecord::new( + "job-owned".to_string(), + DispatchTarget::Ssh { + connection_id: "server-1".to_string(), + workspace_path: "/target".to_string(), + display_name: "Server".to_string(), + }, + "session-1".to_string(), + "/target".to_string(), + "prompt", + "submission_unknown", + ) + .expect("outbound"); + outbound.baseline_worktree_id = Some("worktree-1".to_string()); + outbound.branch = Some("bitfun/dispatch/job".to_string()); + store.bind_if_absent(&outbound).await.expect("bind"); + + let path = store.preparation_path("job-owned").expect("path"); + let mut record: DispatchPreparationRecord = store + .json_store + .read_optional(&path) + .await + .expect("read") + .expect("record"); + record.lease_expires_at = Utc::now() - Duration::minutes(1); + store + .write_preparation_unlocked(&path, &record) + .await + .expect("expire"); + + let release_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let captured = std::sync::Arc::clone(&release_called); + let removed = store + .reconcile_expired_preparations_with(Utc::now(), move |_| { + let captured = std::sync::Arc::clone(&captured); + async move { + captured.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + }) + .await + .expect("reconcile"); + assert_eq!(removed, 0); + assert!(!release_called.load(std::sync::atomic::Ordering::SeqCst)); + assert!( + path.exists(), + "ACK is the only event that removes an owned journal" + ); + } + + #[tokio::test] + async fn crash_before_baseline_attach_uses_the_stable_owner_release() { + let (_temp, store) = store(); + store + .begin_preparation(request("job-pre-claim")) + .await + .expect("begin"); + let path = store.preparation_path("job-pre-claim").expect("path"); + let mut record: DispatchPreparationRecord = store + .json_store + .read_optional(&path) + .await + .expect("read") + .expect("record"); + record.lease_expires_at = Utc::now() - Duration::minutes(1); + store + .write_preparation_unlocked(&path, &record) + .await + .expect("expire"); + + let releases = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let captured = std::sync::Arc::clone(&releases); + assert_eq!( + store + .reconcile_expired_preparations_with(Utc::now(), move |release| { + let captured = std::sync::Arc::clone(&captured); + async move { + captured.lock().expect("capture").push(release); + Ok(()) + } + }) + .await + .expect("reconcile"), + 1 + ); + assert_eq!( + releases.lock().expect("releases").as_slice(), + &[PreparationClaimRelease::ByOwner { + project_workspace_path: "/repo/main".to_string(), + claimed_by: "dispatch:job-pre-claim".to_string(), + }] + ); + } + + #[tokio::test] + async fn unreadable_outbound_ownership_fails_closed() { + let (_temp, store) = store(); + store + .begin_preparation(request("job-unreadable")) + .await + .expect("begin"); + store + .attach_preparation_baseline("job-unreadable", "worktree-1", "bitfun/dispatch/job") + .await + .expect("attach"); + let path = store.preparation_path("job-unreadable").expect("path"); + let mut record: DispatchPreparationRecord = store + .json_store + .read_optional(&path) + .await + .expect("read") + .expect("record"); + record.lease_expires_at = Utc::now() - Duration::minutes(1); + store + .write_preparation_unlocked(&path, &record) + .await + .expect("expire"); + store.ensure_root().await.expect("outbound root"); + fs::write( + store.record_path("job-unreadable").expect("record path"), + b"not json", + ) + .await + .expect("corrupt outbound record"); + + let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let captured = std::sync::Arc::clone(&called); + assert_eq!( + store + .reconcile_expired_preparations_with(Utc::now(), move |_| { + let captured = std::sync::Arc::clone(&captured); + async move { + captured.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + }) + .await + .expect("reconcile"), + 0 + ); + assert!(!called.load(std::sync::atomic::Ordering::SeqCst)); + assert!(path.exists()); + } + + #[tokio::test] + async fn acknowledgement_removes_only_the_journal_payload() { + let (_temp, store) = store(); + store + .begin_preparation(request("job-acked")) + .await + .expect("begin"); + let path = store.preparation_path("job-acked").expect("path"); + assert!(path.exists()); + store + .acknowledge_preparation("job-acked") + .await + .expect("acknowledge"); + assert!(!path.exists()); + store + .acknowledge_preparation("job-acked") + .await + .expect("idempotent acknowledge"); + } + + #[tokio::test] + async fn preparation_run_lock_serializes_same_job_attempts() { + let (_temp, store) = store(); + let first = store + .acquire_preparation_run_lock("job-serialized") + .await + .expect("first lock"); + let waiting_store = store.clone(); + let waiter = tokio::spawn(async move { + waiting_store + .acquire_preparation_run_lock("job-serialized") + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert!( + !waiter.is_finished(), + "a second submit must wait for the active job attempt" + ); + drop(first); + let second = tokio::time::timeout(std::time::Duration::from_secs(2), waiter) + .await + .expect("second lock should wake") + .expect("join waiter") + .expect("second lock"); + drop(second); + } +} diff --git a/src/crates/assembly/core/src/service/dispatch/target.rs b/src/crates/assembly/core/src/service/dispatch/target.rs index a1cc321326..d6245799d1 100644 --- a/src/crates/assembly/core/src/service/dispatch/target.rs +++ b/src/crates/assembly/core/src/service/dispatch/target.rs @@ -1,21 +1,42 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "kebab-case")] -#[derive(Default)] -pub enum DispatchWorkspaceDeliveryRequest { - #[default] - Existing, - SnapshotSource { - #[serde(rename = "sourceWorkspacePath")] - source_workspace_path: String, - }, - SnapshotExact { - #[serde(rename = "sourceWorkspacePath")] - source_workspace_path: String, - #[serde(rename = "sensitiveFilesConfirmed")] - sensitive_files_confirmed: bool, - }, +/// How a dispatch reaches the target: a Git worktree of the controller's own +/// repository, checked out on the target at the same commit. +/// +/// This replaced three file-snapshot delivery modes. A snapshot had no common +/// ancestor with the controller, so results could only be applied by overwriting +/// paths. A shared commit makes the result an ordinary branch the user can +/// fetch, review, merge, or discard. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchWorkspaceDelivery { + /// Controller-side source checkout whose HEAD/local changes seeded the + /// baseline. This may itself be a linked worktree and therefore is not a + /// stable registry lookup path after that checkout is removed. + pub source_workspace_path: String, + /// Canonical main-project workspace that owns the managed-worktree + /// registry. Claim release must use this path rather than the possibly + /// short-lived source checkout above. + #[serde(default)] + pub project_workspace_path: String, + /// Managed worktree created on the controller as this dispatch's baseline. + pub baseline_worktree_id: String, + /// Immutable commit both sides check out. Never a ref name: a ref can move + /// between the controller resolving it and the target fetching it. + pub base_commit: String, + /// Branch the target commits onto, and the branch the controller fetches + /// back during sync. + pub branch: String, + /// Git remote the target clones from. Absent when the repository has no + /// remote, in which case every object is carried by bundle instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_url: Option, + /// Fold the baseline worktree's uncommitted changes into `base_commit`. + /// + /// Only reaches what `git add -A` would stage — unlike the old exact + /// snapshot, ignored files never cross the wire. + #[serde(default)] + pub include_uncommitted: bool, } /// The execution location selected while a chat session is being created. @@ -122,34 +143,45 @@ mod tests { } #[test] - fn exact_snapshot_requires_an_explicit_source_and_confirmation_fact() { - let value = serde_json::to_value(DispatchWorkspaceDeliveryRequest::SnapshotExact { + fn workspace_delivery_pins_an_immutable_commit_in_camel_case() { + let value = serde_json::to_value(DispatchWorkspaceDelivery { source_workspace_path: "/work/app".to_string(), - sensitive_files_confirmed: true, + project_workspace_path: "/work/app-main".to_string(), + baseline_worktree_id: "wt-1".to_string(), + base_commit: "0123456789abcdef0123456789abcdef01234567".to_string(), + branch: "bitfun/dispatch/1a2b3c4d".to_string(), + remote_url: Some("git@example.com:acme/app.git".to_string()), + include_uncommitted: true, }) .expect("serialize delivery"); + assert_eq!( value, serde_json::json!({ - "kind": "snapshot-exact", "sourceWorkspacePath": "/work/app", - "sensitiveFilesConfirmed": true + "projectWorkspacePath": "/work/app-main", + "baselineWorktreeId": "wt-1", + "baseCommit": "0123456789abcdef0123456789abcdef01234567", + "branch": "bitfun/dispatch/1a2b3c4d", + "remoteUrl": "git@example.com:acme/app.git", + "includeUncommitted": true }) ); } #[test] - fn source_snapshot_requires_only_an_explicit_source() { - let value = serde_json::to_value(DispatchWorkspaceDeliveryRequest::SnapshotSource { + fn workspace_delivery_omits_the_remote_for_a_repository_without_one() { + let value = serde_json::to_value(DispatchWorkspaceDelivery { source_workspace_path: "/work/app".to_string(), + project_workspace_path: "/work/app-main".to_string(), + baseline_worktree_id: "wt-1".to_string(), + base_commit: "0123456789abcdef0123456789abcdef01234567".to_string(), + branch: "bitfun/dispatch/1a2b3c4d".to_string(), + remote_url: None, + include_uncommitted: false, }) .expect("serialize delivery"); - assert_eq!( - value, - serde_json::json!({ - "kind": "snapshot-source", - "sourceWorkspacePath": "/work/app" - }) - ); + + assert!(value.get("remoteUrl").is_none()); } } diff --git a/src/crates/assembly/core/src/service/worktree/mod.rs b/src/crates/assembly/core/src/service/worktree/mod.rs index 9366c7c291..d7e153b31e 100644 --- a/src/crates/assembly/core/src/service/worktree/mod.rs +++ b/src/crates/assembly/core/src/service/worktree/mod.rs @@ -66,6 +66,10 @@ pub struct WorktreeCreateRequest { pub base_ref: Option, #[serde(default)] pub copy_local_changes: bool, + /// Marks the new worktree as owned by a caller that must release it later, + /// exempting it from automatic cleanup until then. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claimed_by: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -156,6 +160,16 @@ struct RegisteredWorktree { branch: Option, lifecycle: WorktreeLifecycle, created_at_ms: u64, + /// Owner that still needs this worktree, e.g. `dispatch:`. + /// + /// A claim only suppresses automatic cleanup. It is not a lifecycle: the + /// worktree stays `Managed` and stays manually removable. The claim exists + /// because a claimed worktree can be indistinguishable from an abandoned + /// one — a dispatch baseline has no local session and stays clean until its + /// remote result is synced back, so none of the ordinary safety vetoes + /// (dirty, unpublished commits, associated sessions) would protect it. + #[serde(default, skip_serializing_if = "Option::is_none")] + claimed_by: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -166,6 +180,8 @@ enum WorktreeOperationReceipt { source_workspace_path: String, base_ref: String, copy_local_changes: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + claimed_by: Option, }, CreateBranch { worktree_id: String, @@ -206,6 +222,18 @@ struct RepositoryContext { pub struct WorktreeService; impl WorktreeService { + /// Resolve any checkout (including a linked worktree) to the stable main + /// project path that owns this repository's managed-worktree registry. + /// + /// Dispatch persists this before it creates a claimed baseline so crash + /// recovery never depends on the short-lived checkout that initiated it. + pub async fn resolve_project_workspace_path( + workspace_path: &str, + ) -> Result { + let context = Self::repository_context(Path::new(workspace_path)).await?; + Ok(path_string(&context.project_workspace_path)) + } + /// Stable session identity for an idempotent worktree-session request. pub fn session_id_for_request(request_id: &str) -> Result { validate_request_id(request_id)?; @@ -371,6 +399,12 @@ impl WorktreeService { .unwrap_or_else(|| context.project_workspace_path.clone()); let source_workspace_path = normalized_lookup_path(&source_path); let base_ref = request.base_ref.as_deref().unwrap_or("HEAD").trim(); + let claimed_by = request + .claimed_by + .as_deref() + .map(str::trim) + .filter(|claim| !claim.is_empty()) + .map(ToOwned::to_owned); if let Some(receipt) = registry.receipts.get(&request.request_id).cloned() { return match receipt { @@ -379,11 +413,44 @@ impl WorktreeService { source_workspace_path: receipt_source, base_ref: receipt_base_ref, copy_local_changes, + claimed_by: receipt_claimed_by, } if receipt_source == source_workspace_path && receipt_base_ref == base_ref - && copy_local_changes == request.copy_local_changes => + && copy_local_changes == request.copy_local_changes + && receipt_claimed_by.as_deref() == claimed_by.as_deref() => { - Self::create_result_for_id(&context, &mut registry, &worktree_id, false).await + let claim_restored = Self::restore_create_receipt_claim( + &mut registry, + &worktree_id, + claimed_by.as_deref(), + )?; + if claim_restored { + Self::save_registry(&context, ®istry).await?; + } + let result = + Self::create_result_for_id(&context, &mut registry, &worktree_id, false) + .await; + if result.is_err() + && claim_restored + && Self::clear_matching_claim( + &mut registry, + &worktree_id, + claimed_by.as_deref(), + ) + { + // This invocation reacquired the claim, so it also owns + // rolling that mutation back when result reconciliation + // fails. A pre-existing claim may belong to an in-flight + // or durable dispatch and is never cleared here. + if let Err(cleanup_error) = Self::save_registry(&context, ®istry).await { + log::warn!( + "Failed to roll back a restored worktree claim after create reconciliation failed: worktree_id={} error={}", + worktree_id, + cleanup_error + ); + } + } + result } _ => Err(error( WorktreeErrorCode::RequestConflict, @@ -474,6 +541,7 @@ impl WorktreeService { None }; + let created_claim = claimed_by.clone(); registry.worktrees.push(RegisteredWorktree { worktree_id: worktree_id.clone(), path: path_string(&target_path), @@ -482,6 +550,7 @@ impl WorktreeService { branch: None, lifecycle: WorktreeLifecycle::Managed, created_at_ms: current_unix_ms(), + claimed_by: claimed_by.clone(), }); registry.receipts.insert( request.request_id, @@ -490,6 +559,7 @@ impl WorktreeService { source_workspace_path, base_ref: base_ref.to_string(), copy_local_changes: request.copy_local_changes, + claimed_by, }, ); if let Err(registry_error) = Self::save_registry(&context, ®istry).await { @@ -512,8 +582,28 @@ impl WorktreeService { ); } - let result = - Self::create_result_for_id(&context, &mut registry, &worktree_id, true).await?; + let result = match Self::create_result_for_id(&context, &mut registry, &worktree_id, true) + .await + { + Ok(result) => result, + Err(result_error) => { + if Self::clear_matching_claim(&mut registry, &worktree_id, created_claim.as_deref()) + { + // The worktree and its idempotency receipt remain usable, + // but this failed call must not leave an ownerless retention + // claim behind. A later retry can reacquire the receipt's + // exact claim through `restore_create_receipt_claim`. + if let Err(cleanup_error) = Self::save_registry(&context, ®istry).await { + log::warn!( + "Failed to roll back a new worktree claim after create reconciliation failed: worktree_id={} error={}", + worktree_id, + cleanup_error + ); + } + } + return Err(result_error); + } + }; notify_changed(&context.project_workspace_path).await; Ok(result) } @@ -636,6 +726,62 @@ impl WorktreeService { Ok(result) } + /// Drop a retention claim so the worktree can be cleaned up normally again. + /// + /// Idempotent by construction rather than by receipt: releasing an absent + /// claim, an already-released one, or a worktree that has since been removed + /// all report `false` instead of failing. Callers run this from cleanup + /// paths where an error would strand the claim forever. + pub async fn release_claim( + project_workspace_path: &str, + claimed_by: &str, + ) -> Result { + Self::release_claim_matching(project_workspace_path, None, claimed_by).await + } + + /// Release one exact worktree's claim without affecting another record + /// that may use the same logical owner string. + pub async fn release_claim_for_worktree( + project_workspace_path: &str, + worktree_id: &str, + claimed_by: &str, + ) -> Result { + let worktree_id = worktree_id.trim(); + if worktree_id.is_empty() { + return Ok(false); + } + Self::release_claim_matching(project_workspace_path, Some(worktree_id), claimed_by).await + } + + async fn release_claim_matching( + project_workspace_path: &str, + worktree_id: Option<&str>, + claimed_by: &str, + ) -> Result { + let claim = claimed_by.trim(); + if claim.is_empty() { + return Ok(false); + } + let context = Self::repository_context(Path::new(project_workspace_path)).await?; + let lock = repository_lock(&context.common_git_dir); + let _guard = lock.lock().await; + let _process_guard = Self::acquire_repository_process_lock(&context).await?; + let mut registry = Self::load_registry(&context).await?; + let mut released = false; + for record in &mut registry.worktrees { + if worktree_id.is_none_or(|expected| record.worktree_id == expected) + && record.claimed_by.as_deref() == Some(claim) + { + record.claimed_by = None; + released = true; + } + } + if released { + Self::save_registry(&context, ®istry).await?; + } + Ok(released) + } + pub async fn remove( request: WorktreeRemoveRequest, ) -> Result { @@ -950,6 +1096,7 @@ impl WorktreeService { branch: git_worktree.branch.clone(), lifecycle: WorktreeLifecycle::External, created_at_ms: current_unix_ms(), + claimed_by: None, }); seen_registered_ids.insert(worktree_id.clone()); changed = true; @@ -1083,6 +1230,65 @@ impl WorktreeService { }) } + /// Re-establish the claim recorded by an idempotent create receipt. + /// + /// Cleanup may release a claim before a submit retry reaches this path. A + /// retry is allowed to reacquire only the exact claim bound to its original + /// receipt; it must never adopt an older unclaimed create receipt or steal + /// a worktree that is now held by another owner. + fn restore_create_receipt_claim( + registry: &mut WorktreeRegistry, + worktree_id: &str, + claimed_by: Option<&str>, + ) -> Result { + let record = registry + .worktrees + .iter_mut() + .find(|record| record.worktree_id == worktree_id) + .ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + "Idempotent worktree result no longer exists", + ) + })?; + + match (record.claimed_by.as_deref(), claimed_by) { + (None, Some(claim)) => { + record.claimed_by = Some(claim.to_string()); + Ok(true) + } + (None, None) => Ok(false), + (Some(existing), Some(claim)) if existing == claim => Ok(false), + _ => Err(error( + WorktreeErrorCode::RequestConflict, + "The idempotent worktree claim no longer matches its creation receipt", + )), + } + } + + /// Clear only the claim introduced by the current create attempt. + fn clear_matching_claim( + registry: &mut WorktreeRegistry, + worktree_id: &str, + claimed_by: Option<&str>, + ) -> bool { + let Some(claim) = claimed_by else { + return false; + }; + let Some(record) = registry + .worktrees + .iter_mut() + .find(|record| record.worktree_id == worktree_id) + else { + return false; + }; + if record.claimed_by.as_deref() != Some(claim) { + return false; + } + record.claimed_by = None; + true + } + async fn mutation_result_for_id( context: &RepositoryContext, registry: &mut WorktreeRegistry, @@ -1298,6 +1504,7 @@ fn automatic_delete_candidate_ids( .into_iter() .skip(limit.max(1)) .filter(|record| record.worktree_id != protected_worktree_id) + .filter(|record| record.claimed_by.is_none()) .filter(|record| now_ms.saturating_sub(record.created_at_ms) >= AUTO_DELETE_MIN_AGE_MS) .map(|record| record.worktree_id.clone()) .collect() @@ -2045,6 +2252,7 @@ mod tests { branch: None, lifecycle, created_at_ms, + claimed_by: None, }); } @@ -2054,6 +2262,35 @@ mod tests { ); } + #[test] + fn automatic_cleanup_never_selects_a_claimed_worktree() { + let project = Path::new("/repo"); + let mut registry = WorktreeRegistry::new(project); + for (worktree_id, created_at_ms, claimed_by) in [ + ("newest", 30, None), + ("unclaimed", 20, None), + ("claimed", 10, Some("dispatch:job-1")), + ] { + registry.worktrees.push(RegisteredWorktree { + worktree_id: worktree_id.to_string(), + path: format!("/worktrees/{worktree_id}"), + base_ref: Some("main".to_string()), + base_commit: "0123456789abcdef".to_string(), + branch: None, + lifecycle: WorktreeLifecycle::Managed, + created_at_ms, + claimed_by: claimed_by.map(ToOwned::to_owned), + }); + } + + // A dispatch baseline is clean, session-less, and older than the grace + // period, so only the claim keeps it alive. + assert_eq!( + automatic_delete_candidate_ids(®istry, 1, "newest", AUTO_DELETE_MIN_AGE_MS + 100,), + vec!["unclaimed".to_string()] + ); + } + #[test] fn automatic_cleanup_never_selects_the_newly_created_worktree() { let project = Path::new("/repo"); @@ -2067,6 +2304,7 @@ mod tests { branch: None, lifecycle: WorktreeLifecycle::Managed, created_at_ms: 10, + claimed_by: None, }); } @@ -2089,6 +2327,7 @@ mod tests { branch: None, lifecycle: WorktreeLifecycle::Managed, created_at_ms, + claimed_by: None, }); } @@ -2119,6 +2358,7 @@ mod tests { branch: None, lifecycle: WorktreeLifecycle::Managed, created_at_ms: 123, + claimed_by: Some("dispatch:job-restored".to_string()), }); registry.receipts.insert( "request-restored".to_string(), @@ -2127,6 +2367,7 @@ mod tests { source_workspace_path: project.to_string_lossy().to_string(), base_ref: "main".to_string(), copy_local_changes: false, + claimed_by: Some("dispatch:job-restored".to_string()), }, ); @@ -2139,6 +2380,10 @@ mod tests { assert_eq!(restored.worktrees.len(), 1); assert_eq!(restored.worktrees[0].worktree_id, "wt-restored"); + assert_eq!( + restored.worktrees[0].claimed_by.as_deref(), + Some("dispatch:job-restored") + ); assert_eq!( restored .receipts @@ -2147,5 +2392,120 @@ mod tests { .worktree_id(), "wt-restored" ); + match restored.receipts.get("request-restored").expect("receipt") { + WorktreeOperationReceipt::Create { claimed_by, .. } => assert_eq!( + claimed_by.as_deref(), + Some("dispatch:job-restored"), + "the receipt must retain the claim needed by an idempotent retry" + ), + receipt => panic!("unexpected receipt: {receipt:?}"), + } + } + + #[test] + fn create_receipt_reacquires_only_its_recorded_claim() { + let project = Path::new("/repo"); + let mut registry = WorktreeRegistry::new(project); + registry.worktrees.push(RegisteredWorktree { + worktree_id: "wt-claimed".to_string(), + path: "/managed/wt-claimed".to_string(), + base_ref: Some("main".to_string()), + base_commit: "0123456789abcdef".to_string(), + branch: None, + lifecycle: WorktreeLifecycle::Managed, + created_at_ms: 123, + claimed_by: None, + }); + + assert!(WorktreeService::restore_create_receipt_claim( + &mut registry, + "wt-claimed", + Some("dispatch:job-1"), + ) + .expect("reacquire released claim")); + assert_eq!( + registry.worktrees[0].claimed_by.as_deref(), + Some("dispatch:job-1") + ); + assert!(!WorktreeService::restore_create_receipt_claim( + &mut registry, + "wt-claimed", + Some("dispatch:job-1"), + ) + .expect("same claim is idempotent")); + + let conflict = WorktreeService::restore_create_receipt_claim( + &mut registry, + "wt-claimed", + Some("dispatch:job-2"), + ) + .expect_err("a retry must not steal another claim"); + assert_eq!(conflict.code, WorktreeErrorCode::RequestConflict); + assert_eq!( + registry.worktrees[0].claimed_by.as_deref(), + Some("dispatch:job-1") + ); + } + + #[test] + fn failed_create_cleanup_clears_only_the_exact_attempt_claim() { + let project = Path::new("/repo"); + let mut registry = WorktreeRegistry::new(project); + for (worktree_id, claimed_by) in [ + ("wt-current", Some("dispatch:job-1")), + ("wt-other", Some("dispatch:job-2")), + ] { + registry.worktrees.push(RegisteredWorktree { + worktree_id: worktree_id.to_string(), + path: format!("/managed/{worktree_id}"), + base_ref: Some("main".to_string()), + base_commit: "0123456789abcdef".to_string(), + branch: None, + lifecycle: WorktreeLifecycle::Managed, + created_at_ms: 123, + claimed_by: claimed_by.map(ToOwned::to_owned), + }); + } + + assert!(!WorktreeService::clear_matching_claim( + &mut registry, + "wt-current", + Some("dispatch:job-2") + )); + assert!(!WorktreeService::clear_matching_claim( + &mut registry, + "missing", + Some("dispatch:job-1") + )); + assert!(WorktreeService::clear_matching_claim( + &mut registry, + "wt-current", + Some("dispatch:job-1") + )); + assert_eq!(registry.worktrees[0].claimed_by, None); + assert_eq!( + registry.worktrees[1].claimed_by.as_deref(), + Some("dispatch:job-2"), + "cleanup must not release another worktree's claim" + ); + } + + #[test] + fn legacy_create_receipt_defaults_to_unclaimed() { + let receipt: WorktreeOperationReceipt = serde_json::from_value(serde_json::json!({ + "operation": "create", + "worktree_id": "wt-legacy", + "source_workspace_path": "/repo", + "base_ref": "main", + "copy_local_changes": false + })) + .expect("legacy receipt"); + + match receipt { + WorktreeOperationReceipt::Create { claimed_by, .. } => { + assert_eq!(claimed_by, None); + } + receipt => panic!("unexpected receipt: {receipt:?}"), + } } } diff --git a/src/crates/assembly/core/src/service/worktree/session_binding.rs b/src/crates/assembly/core/src/service/worktree/session_binding.rs index 598a685f50..fe9a129a4d 100644 --- a/src/crates/assembly/core/src/service/worktree/session_binding.rs +++ b/src/crates/assembly/core/src/service/worktree/session_binding.rs @@ -298,6 +298,8 @@ impl WorktreeService { source_workspace_path: Some(context.execution_target.root_path.clone()), base_ref: None, copy_local_changes: settings.copy_local_changes, + // A bound session is the claim: it already blocks automatic removal. + claimed_by: None, }) .await?; diff --git a/src/crates/contracts/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs index 561c05707d..71517f2ff1 100644 --- a/src/crates/contracts/events/src/frontend_projection.rs +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -198,6 +198,7 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Option, - pub executable: bool, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum WorkspaceSnapshotEntryKind { - File, - Directory, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct WorkspaceSnapshotManifest { - pub format_version: u32, - pub mode: String, - pub includes_ignored_files: bool, - pub excludes_git_metadata: bool, - pub file_count: u64, - pub directory_count: u64, - pub uncompressed_bytes: u64, - pub entries: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct WorkspaceSnapshotMetadata { - pub format_version: u32, - pub archive_size: u64, - pub archive_sha256: String, - pub manifest_sha256: String, - pub file_count: u64, - pub directory_count: u64, - pub uncompressed_bytes: u64, -} - -/// Cheaply recomputable state of the source tree used to create a snapshot. -/// -/// This is a controller-local cache key, not part of the target wire metadata. -/// It covers the selected portable paths plus file identity, size, write/change -/// timestamps, and executable state without rereading file contents. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct WorkspaceSnapshotSourceFingerprint { - pub format_version: u32, - pub sha256: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PreparedWorkspaceSnapshot { - pub metadata: WorkspaceSnapshotMetadata, - pub source_fingerprint: WorkspaceSnapshotSourceFingerprint, - /// The per-file manifest that was sealed into the archive. - /// - /// Packaging already hashes every file while writing the tar stream, so - /// handing this back lets a controller cache answer "did the content - /// actually change?" without repacking. - pub manifest: WorkspaceSnapshotManifest, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum WorkspaceSnapshotCaptureMode { - Source, - Exact, -} - -impl WorkspaceSnapshotCaptureMode { - fn manifest_mode(self) -> &'static str { - // The transport envelope remains the existing exact-snapshot contract: - // source filtering happens while the controller captures the input set, - // then that complete captured set is signed and transferred exactly. - "exact" - } - - fn includes_ignored_files(self) -> bool { - // True relative to the captured input set. Source mode has already - // removed ignored paths before the manifest is constructed. - true - } -} - -/// What the target changed, relative to the snapshot it was given. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct WorkspaceResultSummary { - pub added: Vec, - pub modified: Vec, - pub deleted: Vec, - /// Snapshot digest of every path the target changed or removed. - /// - /// Carried so the controller can tell a clean apply from one that would - /// discard local edits: if the local file still matches this, the target's - /// change is the only one; if it does not, both sides moved. - #[serde(default)] - pub baseline_sha256: BTreeMap, - pub archive_size: u64, - pub archive_sha256: String, -} - -impl WorkspaceResultSummary { - pub fn is_empty(&self) -> bool { - self.added.is_empty() && self.modified.is_empty() && self.deleted.is_empty() - } -} - -/// Diff the terminal target tree against the delivered snapshot and package -/// only what changed. -/// -/// Content-addressed rather than git-based: the baseline manifest already -/// carries a SHA-256 per file, so this works for workspaces that are not -/// repositories — which is most of the reason snapshot mode exists. -/// -/// Produces the bundle only; applying it locally stays a separate, explicitly -/// confirmed step. -pub fn create_workspace_result_bundle( - workspace: &Path, - baseline: &WorkspaceSnapshotManifest, - archive_path: &Path, -) -> Result { - let workspace = workspace - .canonicalize() - .with_context(|| format!("resolve dispatch workspace {}", workspace.display()))?; - let baseline_files: BTreeMap<&str, &WorkspaceSnapshotEntry> = baseline - .entries - .iter() - .filter(|entry| entry.kind == WorkspaceSnapshotEntryKind::File) - .map(|entry| (entry.path.as_str(), entry)) - .collect(); - - let archive_file = File::create(archive_path) - .with_context(|| format!("create result bundle {}", archive_path.display()))?; - let encoder = GzEncoder::new(archive_file, Compression::default()); - let mut archive = Builder::new(encoder); - archive.mode(tar::HeaderMode::Deterministic); - - let mut walk = WalkBuilder::new(&workspace); - walk.hidden(false) - .ignore(false) - .git_ignore(false) - .git_global(false) - .git_exclude(false) - .parents(false) - .follow_links(false) - .sort_by_file_path(|left, right| left.cmp(right)); - let filter_root = workspace.clone(); - walk.filter_entry(move |entry| { - entry.path() == filter_root || entry.file_name().to_str() != Some(".git") - }); - - let mut summary = WorkspaceResultSummary::default(); - let mut seen = BTreeSet::new(); - let mut file_count = 0_u64; - let mut changed_bytes = 0_u64; - for walked in walk.build() { - let walked = walked.context("walk dispatch workspace for result bundle")?; - let path = walked.path(); - if path == workspace { - continue; - } - let relative = path - .strip_prefix(&workspace) - .with_context(|| format!("resolve result path {}", path.display()))?; - let relative_wire = portable_relative_path(relative)?; - let metadata = fs::symlink_metadata(path) - .with_context(|| format!("inspect result entry {}", path.display()))?; - // Same rule as packaging: an unsupported entry fails the whole - // operation rather than yielding a bundle that silently omits it. - if metadata.file_type().is_symlink() { - bail!("dispatch result contains a symlink: {relative_wire}"); - } - if metadata.is_dir() { - continue; - } - if !metadata.is_file() { - bail!("dispatch result contains an unsupported entry: {relative_wire}"); - } - seen.insert(relative_wire.clone()); - file_count += 1; - if file_count > MAX_SNAPSHOT_FILES { - bail!("dispatch result exceeds the {MAX_SNAPSHOT_FILES} file limit"); - } - if metadata.len() > MAX_SNAPSHOT_FILE_BYTES { - bail!("dispatch result file exceeds the size limit: {relative_wire}"); - } - - let digest = sha256_file(path)?; - let existing = baseline_files.get(relative_wire.as_str()); - let unchanged = existing - .and_then(|entry| entry.sha256.as_deref()) - .is_some_and(|baseline_digest| baseline_digest.eq_ignore_ascii_case(&digest)); - if unchanged { - continue; - } - - changed_bytes = changed_bytes.saturating_add(metadata.len()); - if changed_bytes > MAX_SNAPSHOT_UNCOMPRESSED_BYTES { - bail!("dispatch result exceeds the uncompressed size limit"); - } - let executable = is_executable(&metadata); - append_file(&mut archive, path, &relative_wire, &metadata, executable)?; - if let Some(entry) = existing { - if let Some(baseline_digest) = entry.sha256.as_deref() { - summary - .baseline_sha256 - .insert(relative_wire.clone(), baseline_digest.to_string()); - } - summary.modified.push(relative_wire); - } else { - summary.added.push(relative_wire); - } - } - - for (path, entry) in &baseline_files { - if seen.contains(*path) { - continue; - } - summary.deleted.push((*path).to_string()); - if let Some(baseline_digest) = entry.sha256.as_deref() { - summary - .baseline_sha256 - .insert((*path).to_string(), baseline_digest.to_string()); - } - } - - let summary_bytes = serde_json::to_vec(&WorkspaceResultSummary { - archive_sha256: String::new(), - archive_size: 0, - ..summary.clone() - }) - .context("encode dispatch result summary")?; - append_bytes( - &mut archive, - RESULT_SUMMARY_ARCHIVE_PATH, - &summary_bytes, - false, - )?; - - archive - .into_inner() - .context("finalize dispatch result bundle")? - .finish() - .context("compress dispatch result bundle")? - .sync_all() - .context("flush dispatch result bundle")?; - - let archive_metadata = fs::metadata(archive_path) - .with_context(|| format!("inspect result bundle {}", archive_path.display()))?; - summary.archive_size = archive_metadata.len(); - if summary.archive_size > MAX_SNAPSHOT_ARCHIVE_BYTES { - bail!("dispatch result bundle exceeds the archive size limit"); - } - summary.archive_sha256 = sha256_file(archive_path)?; - Ok(summary) -} - -/// A local path both sides changed since the snapshot was taken. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct WorkspaceResultConflict { - pub path: String, - /// Why the local file no longer matches the snapshot. - pub reason: WorkspaceResultConflictReason, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum WorkspaceResultConflictReason { - /// Edited locally after the snapshot, and edited on the target too. - LocallyModified, - /// Deleted locally, but the target changed it. - LocallyMissing, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WorkspaceResultApplyOutcome { - pub written: Vec, - pub removed: Vec, - pub conflicts: Vec, - /// True when nothing was touched because conflicts were found. - pub aborted: bool, -} - -/// Report which paths a result bundle would overwrite that also changed locally. -/// -/// The controller and the target diverged independently after `S0`, so this is -/// the difference between "apply the target's work" and "silently discard mine". -pub fn inspect_workspace_result_conflicts( - workspace: &Path, - summary: &WorkspaceResultSummary, -) -> Result> { - let workspace = workspace - .canonicalize() - .with_context(|| format!("resolve workspace {}", workspace.display()))?; - let mut conflicts = Vec::new(); - for (path, baseline_digest) in &summary.baseline_sha256 { - let local = resolve_workspace_child(&workspace, path)?; - match fs::symlink_metadata(&local) { - Ok(metadata) if metadata.is_file() => { - if !sha256_file(&local)?.eq_ignore_ascii_case(baseline_digest) { - conflicts.push(WorkspaceResultConflict { - path: path.clone(), - reason: WorkspaceResultConflictReason::LocallyModified, - }); - } - } - // A path the snapshot contained that is now gone or is no longer a - // regular file: applying would resurrect or clobber it. - Ok(_) | Err(_) => conflicts.push(WorkspaceResultConflict { - path: path.clone(), - reason: WorkspaceResultConflictReason::LocallyMissing, - }), - } - } - Ok(conflicts) -} - -/// Apply a verified result bundle to a local workspace. -/// -/// Refuses to touch anything when a conflict is found unless `overwrite` is -/// set, so the default outcome of a surprise is nothing rather than a -/// half-merged tree. -pub fn apply_workspace_result_bundle( - bundle_path: &Path, - workspace: &Path, - summary: &WorkspaceResultSummary, - overwrite: bool, -) -> Result { - let actual = sha256_file(bundle_path)?; - if !actual.eq_ignore_ascii_case(&summary.archive_sha256) { - bail!("dispatch result bundle does not match the reported digest"); - } - let conflicts = inspect_workspace_result_conflicts(workspace, summary)?; - if !conflicts.is_empty() && !overwrite { - return Ok(WorkspaceResultApplyOutcome { - conflicts, - aborted: true, - ..Default::default() - }); - } - let workspace = workspace - .canonicalize() - .with_context(|| format!("resolve workspace {}", workspace.display()))?; - - let expected: BTreeSet<&str> = summary - .added - .iter() - .chain(summary.modified.iter()) - .map(String::as_str) - .collect(); - let file = File::open(bundle_path) - .with_context(|| format!("open result bundle {}", bundle_path.display()))?; - let mut archive = Archive::new(GzDecoder::new(file)); - let mut outcome = WorkspaceResultApplyOutcome { - conflicts, - ..Default::default() - }; - for entry in archive.entries().context("read result bundle")? { - let mut entry = entry.context("read result bundle entry")?; - let entry_path = entry.path().context("read result bundle entry path")?; - let Ok(relative) = entry_path.strip_prefix(WORKSPACE_ARCHIVE_ROOT) else { - continue; // the bundle's own metadata - }; - let relative_wire = portable_relative_path(relative)?; - if !expected.contains(relative_wire.as_str()) { - bail!("dispatch result bundle contains an unreported path: {relative_wire}"); - } - if entry.header().entry_type() != EntryType::Regular { - bail!("dispatch result bundle contains a non-regular entry: {relative_wire}"); - } - let destination = resolve_workspace_child(&workspace, &relative_wire)?; - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; - } - let mut bytes = Vec::new(); - entry - .read_to_end(&mut bytes) - .with_context(|| format!("read {relative_wire} from result bundle"))?; - fs::write(&destination, &bytes) - .with_context(|| format!("write {}", destination.display()))?; - outcome.written.push(relative_wire); - } - - for path in &summary.deleted { - let destination = resolve_workspace_child(&workspace, path)?; - match fs::symlink_metadata(&destination) { - Ok(metadata) if metadata.is_file() => { - fs::remove_file(&destination) - .with_context(|| format!("remove {}", destination.display()))?; - outcome.removed.push(path.clone()); - } - // Already gone locally: the desired end state, nothing to do. - _ => {} - } - } - Ok(outcome) -} - -/// Join a manifest-relative path under the workspace, refusing anything that -/// would land outside it. -fn resolve_workspace_child(workspace: &Path, relative_wire: &str) -> Result { - if relative_wire.is_empty() { - bail!("dispatch result path is empty"); - } - let mut resolved = workspace.to_path_buf(); - for part in relative_wire.split('/') { - if part.is_empty() || part == "." || part == ".." { - bail!("dispatch result path is not workspace-relative: {relative_wire}"); - } - resolved.push(part); - } - ensure_path_below(workspace, &resolved)?; - Ok(resolved) -} - -/// Package every regular workspace file, including hidden and ignored files. -/// -/// `.git` entries are the one explicit metadata exclusion. Unsupported entries -/// fail the whole operation instead of producing an incomplete snapshot. -pub fn create_exact_workspace_snapshot( - source: &Path, - archive_path: &Path, -) -> Result { - Ok(prepare_exact_workspace_snapshot(source, archive_path)?.metadata) -} - -pub fn prepare_exact_workspace_snapshot( - source: &Path, - archive_path: &Path, -) -> Result { - create_workspace_snapshot(source, archive_path, WorkspaceSnapshotCaptureMode::Exact) -} - -/// Package workspace source while honoring repository ignore rules. -/// -/// Hidden source files remain eligible (for example `.github/workflows`), but -/// ignored dependency caches and build output are not transferred. Callers -/// that need byte-for-byte workspace contents must use the explicit exact -/// snapshot path instead. -pub fn create_source_workspace_snapshot( - source: &Path, - archive_path: &Path, -) -> Result { - Ok(prepare_source_workspace_snapshot(source, archive_path)?.metadata) -} - -pub fn prepare_source_workspace_snapshot( - source: &Path, - archive_path: &Path, -) -> Result { - create_workspace_snapshot(source, archive_path, WorkspaceSnapshotCaptureMode::Source) -} - -pub fn exact_workspace_snapshot_source_fingerprint( - source: &Path, -) -> Result { - workspace_snapshot_source_fingerprint(source, WorkspaceSnapshotCaptureMode::Exact) -} - -pub fn source_workspace_snapshot_source_fingerprint( - source: &Path, -) -> Result { - workspace_snapshot_source_fingerprint(source, WorkspaceSnapshotCaptureMode::Source) -} - -pub fn exact_workspace_matches_manifest( - source: &Path, - manifest: &WorkspaceSnapshotManifest, -) -> Result { - workspace_matches_manifest(source, WorkspaceSnapshotCaptureMode::Exact, manifest) -} - -pub fn source_workspace_matches_manifest( - source: &Path, - manifest: &WorkspaceSnapshotManifest, -) -> Result { - workspace_matches_manifest(source, WorkspaceSnapshotCaptureMode::Source, manifest) -} - -fn create_workspace_snapshot( - source: &Path, - archive_path: &Path, - capture_mode: WorkspaceSnapshotCaptureMode, -) -> Result { - let result = create_workspace_snapshot_inner(source, archive_path, capture_mode); - if result.is_err() { - let _ = fs::remove_file(archive_path); - } - result -} - -fn create_workspace_snapshot_inner( - source: &Path, - archive_path: &Path, - capture_mode: WorkspaceSnapshotCaptureMode, -) -> Result { - let source_metadata = fs::symlink_metadata(source) - .with_context(|| format!("inspect workspace {}", source.display()))?; - if source_metadata.file_type().is_symlink() || !source_metadata.is_dir() { - bail!( - "workspace snapshot source is not a real directory: {}", - source.display() - ); - } - let source = source - .canonicalize() - .with_context(|| format!("resolve workspace {}", source.display()))?; - let archive_parent = archive_path - .parent() - .ok_or_else(|| anyhow!("workspace archive path has no parent"))?; - fs::create_dir_all(archive_parent) - .with_context(|| format!("create snapshot staging {}", archive_parent.display()))?; - - let archive_file = OpenOptions::new() - .write(true) - .create_new(true) - .open(archive_path) - .with_context(|| format!("create workspace snapshot {}", archive_path.display()))?; - set_private_file_permissions(archive_path)?; - let encoder = GzEncoder::new(archive_file, Compression::default()); - let mut archive = Builder::new(encoder); - archive.mode(tar::HeaderMode::Deterministic); - - let mut entries = Vec::new(); - let mut file_count = 0_u64; - let mut directory_count = 0_u64; - let mut uncompressed_bytes = 0_u64; - let mut source_fingerprint = new_source_fingerprint(capture_mode); - for walked in workspace_walk(&source, capture_mode) { - let walked = walked.context("walk workspace for dispatch snapshot")?; - let path = walked.path(); - if path == source { - continue; - } - let relative = path - .strip_prefix(&source) - .with_context(|| format!("resolve snapshot path {}", path.display()))?; - let relative_wire = portable_relative_path(relative)?; - let metadata = fs::symlink_metadata(path) - .with_context(|| format!("inspect snapshot entry {}", path.display()))?; - if metadata.file_type().is_symlink() { - bail!( - "workspace snapshot does not support symbolic link '{}'", - relative_wire - ); - } - if metadata.is_dir() { - directory_count = directory_count.saturating_add(1); - if directory_count > MAX_SNAPSHOT_DIRECTORIES { - bail!( - "workspace snapshot exceeds the {} directory limit", - MAX_SNAPSHOT_DIRECTORIES - ); - } - append_directory(&mut archive, &relative_wire)?; - update_directory_source_fingerprint(&mut source_fingerprint, &relative_wire); - entries.push(WorkspaceSnapshotEntry { - path: relative_wire, - kind: WorkspaceSnapshotEntryKind::Directory, - size: 0, - sha256: None, - executable: false, - }); - continue; - } - if !metadata.is_file() { - bail!( - "workspace snapshot contains unsupported special file '{}'", - relative_wire - ); - } - let size = metadata.len(); - if size > MAX_SNAPSHOT_FILE_BYTES { - bail!( - "workspace snapshot file '{}' exceeds the {} MiB per-file limit", - relative_wire, - MAX_SNAPSHOT_FILE_BYTES / (1024 * 1024) - ); - } - file_count = file_count.saturating_add(1); - if file_count > MAX_SNAPSHOT_FILES { - bail!( - "workspace snapshot exceeds the {} file limit", - MAX_SNAPSHOT_FILES - ); - } - uncompressed_bytes = uncompressed_bytes.saturating_add(size); - if uncompressed_bytes > MAX_SNAPSHOT_UNCOMPRESSED_BYTES { - bail!( - "workspace snapshot exceeds the {} MiB uncompressed limit", - MAX_SNAPSHOT_UNCOMPRESSED_BYTES / (1024 * 1024) - ); - } - let executable = is_executable(&metadata); - update_file_source_fingerprint( - &mut source_fingerprint, - &relative_wire, - &metadata, - executable, - )?; - let sha256 = append_file(&mut archive, path, &relative_wire, &metadata, executable)?; - entries.push(WorkspaceSnapshotEntry { - path: relative_wire, - kind: WorkspaceSnapshotEntryKind::File, - size, - sha256: Some(sha256), - executable, - }); - } - - let manifest = WorkspaceSnapshotManifest { - format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, - mode: capture_mode.manifest_mode().to_string(), - includes_ignored_files: capture_mode.includes_ignored_files(), - excludes_git_metadata: true, - file_count, - directory_count, - uncompressed_bytes, - entries, - }; - let manifest_bytes = - serde_json::to_vec(&manifest).context("encode workspace snapshot manifest")?; - if manifest_bytes.len() as u64 > MAX_MANIFEST_BYTES { - bail!("workspace snapshot manifest exceeds the safety limit"); - } - append_bytes(&mut archive, MANIFEST_ARCHIVE_PATH, &manifest_bytes, false)?; - let manifest_sha256 = sha256_bytes(&manifest_bytes); - - let encoder = archive - .into_inner() - .context("finish workspace snapshot tar stream")?; - let archive_file = encoder - .finish() - .context("finish workspace snapshot compression")?; - archive_file - .sync_all() - .context("sync workspace snapshot archive")?; - let archive_size = archive_file - .metadata() - .context("inspect workspace snapshot archive")? - .len(); - drop(archive_file); - if archive_size > MAX_SNAPSHOT_ARCHIVE_BYTES { - bail!( - "workspace snapshot archive exceeds the {} MiB compressed limit", - MAX_SNAPSHOT_ARCHIVE_BYTES / (1024 * 1024) - ); - } - let archive_sha256 = sha256_file(archive_path)?; - Ok(PreparedWorkspaceSnapshot { - metadata: WorkspaceSnapshotMetadata { - format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, - archive_size, - archive_sha256, - manifest_sha256, - file_count, - directory_count, - uncompressed_bytes, - }, - source_fingerprint: finish_source_fingerprint(source_fingerprint), - manifest, - }) -} - -fn workspace_snapshot_source_fingerprint( - source: &Path, - capture_mode: WorkspaceSnapshotCaptureMode, -) -> Result { - let source_metadata = fs::symlink_metadata(source) - .with_context(|| format!("inspect workspace {}", source.display()))?; - if source_metadata.file_type().is_symlink() || !source_metadata.is_dir() { - bail!( - "workspace snapshot source is not a real directory: {}", - source.display() - ); - } - let source = source - .canonicalize() - .with_context(|| format!("resolve workspace {}", source.display()))?; - let mut fingerprint = new_source_fingerprint(capture_mode); - let mut file_count = 0_u64; - let mut directory_count = 0_u64; - let mut uncompressed_bytes = 0_u64; - for walked in workspace_walk(&source, capture_mode) { - let walked = walked.context("walk workspace for dispatch snapshot fingerprint")?; - let path = walked.path(); - if path == source { - continue; - } - let relative = path - .strip_prefix(&source) - .with_context(|| format!("resolve snapshot path {}", path.display()))?; - let relative_wire = portable_relative_path(relative)?; - let metadata = fs::symlink_metadata(path) - .with_context(|| format!("inspect snapshot entry {}", path.display()))?; - if metadata.file_type().is_symlink() { - bail!( - "workspace snapshot does not support symbolic link '{}'", - relative_wire - ); - } - if metadata.is_dir() { - directory_count = directory_count.saturating_add(1); - if directory_count > MAX_SNAPSHOT_DIRECTORIES { - bail!( - "workspace snapshot exceeds the {} directory limit", - MAX_SNAPSHOT_DIRECTORIES - ); - } - update_directory_source_fingerprint(&mut fingerprint, &relative_wire); - continue; - } - if !metadata.is_file() { - bail!( - "workspace snapshot contains unsupported special file '{}'", - relative_wire - ); - } - if metadata.len() > MAX_SNAPSHOT_FILE_BYTES { - bail!( - "workspace snapshot file '{}' exceeds the {} MiB per-file limit", - relative_wire, - MAX_SNAPSHOT_FILE_BYTES / (1024 * 1024) - ); - } - file_count = file_count.saturating_add(1); - if file_count > MAX_SNAPSHOT_FILES { - bail!( - "workspace snapshot exceeds the {} file limit", - MAX_SNAPSHOT_FILES - ); - } - uncompressed_bytes = uncompressed_bytes.saturating_add(metadata.len()); - if uncompressed_bytes > MAX_SNAPSHOT_UNCOMPRESSED_BYTES { - bail!( - "workspace snapshot exceeds the {} MiB uncompressed limit", - MAX_SNAPSHOT_UNCOMPRESSED_BYTES / (1024 * 1024) - ); - } - update_file_source_fingerprint( - &mut fingerprint, - &relative_wire, - &metadata, - is_executable(&metadata), - )?; - } - Ok(finish_source_fingerprint(fingerprint)) -} - -/// Decide whether a source tree still produces the contents of a known manifest. -/// -/// The source fingerprint is deliberately cheap, so it also reports a change for -/// content-neutral operations: `chmod`, a `git checkout` round trip, an editor's -/// write-then-rename (new inode), or a backup tool touching timestamps. This is -/// the more expensive second opinion, and it is only worth asking after the -/// fingerprint already disagreed. -/// -/// Two passes, cheapest first: -/// 1. Structure, using stat data only. Any added, removed, resized, or -/// re-typed entry, or a flipped executable bit, rejects at today's cost. -/// 2. Content, only once the structure matched exactly. Each file is hashed and -/// compared against the digest packaging recorded for it. -/// -/// Anything this function cannot verify — a symlink, a special file, a manifest -/// entry with no recorded digest — is reported as "does not match" so the caller -/// repacks. Repacking surfaces the real diagnostic for those cases. -fn workspace_matches_manifest( - source: &Path, - capture_mode: WorkspaceSnapshotCaptureMode, - manifest: &WorkspaceSnapshotManifest, -) -> Result { - let source_metadata = fs::symlink_metadata(source) - .with_context(|| format!("inspect workspace {}", source.display()))?; - if source_metadata.file_type().is_symlink() || !source_metadata.is_dir() { - bail!( - "workspace snapshot source is not a real directory: {}", - source.display() - ); - } - let source = source - .canonicalize() - .with_context(|| format!("resolve workspace {}", source.display()))?; - - let mut expected: BTreeMap<&str, &WorkspaceSnapshotEntry> = manifest - .entries - .iter() - .map(|entry| (entry.path.as_str(), entry)) - .collect(); - let mut pending_content: Vec<(PathBuf, &str)> = Vec::new(); - - for walked in workspace_walk(&source, capture_mode) { - let walked = walked.context("walk workspace for dispatch snapshot comparison")?; - let path = walked.path(); - if path == source { - continue; - } - let relative = path - .strip_prefix(&source) - .with_context(|| format!("resolve snapshot path {}", path.display()))?; - let relative_wire = portable_relative_path(relative)?; - let metadata = fs::symlink_metadata(path) - .with_context(|| format!("inspect snapshot entry {}", path.display()))?; - let Some(entry) = expected.remove(relative_wire.as_str()) else { - // Added since the snapshot was packaged. - return Ok(false); - }; - if metadata.file_type().is_symlink() || !(metadata.is_dir() || metadata.is_file()) { - return Ok(false); - } - if metadata.is_dir() { - if entry.kind != WorkspaceSnapshotEntryKind::Directory { - return Ok(false); - } - continue; - } - if entry.kind != WorkspaceSnapshotEntryKind::File - || entry.size != metadata.len() - || entry.executable != is_executable(&metadata) - { - return Ok(false); - } - let Some(digest) = entry.sha256.as_deref() else { - return Ok(false); - }; - pending_content.push((path.to_path_buf(), digest)); - } - - if !expected.is_empty() { - // Removed since the snapshot was packaged. - return Ok(false); - } - - for (path, expected_digest) in pending_content { - if !expected_digest.eq_ignore_ascii_case(&sha256_file(&path)?) { - return Ok(false); - } - } - Ok(true) -} - -fn workspace_walk(source: &Path, capture_mode: WorkspaceSnapshotCaptureMode) -> ignore::Walk { - let mut walk = WalkBuilder::new(source); - walk.hidden(false).follow_links(false); - match capture_mode { - WorkspaceSnapshotCaptureMode::Source => { - walk.ignore(true) - .git_ignore(true) - .git_global(true) - .git_exclude(true) - .require_git(false) - .parents(false); - } - WorkspaceSnapshotCaptureMode::Exact => { - walk.ignore(false) - .git_ignore(false) - .git_global(false) - .git_exclude(false) - .parents(false); - } - } - walk.sort_by_file_path(|left, right| left.cmp(right)); - let filter_root = source.to_path_buf(); - walk.filter_entry(move |entry| { - entry.path() == filter_root || entry.file_name().to_str() != Some(".git") - }); - walk.build() -} - -fn new_source_fingerprint(capture_mode: WorkspaceSnapshotCaptureMode) -> Sha256 { - let mut fingerprint = Sha256::new(); - fingerprint.update(b"bitfun-dispatch-workspace-source-fingerprint"); - fingerprint.update(WORKSPACE_SNAPSHOT_SOURCE_FINGERPRINT_VERSION.to_le_bytes()); - fingerprint.update(match capture_mode { - WorkspaceSnapshotCaptureMode::Source => b"source".as_slice(), - WorkspaceSnapshotCaptureMode::Exact => b"exact".as_slice(), - }); - fingerprint -} - -fn update_directory_source_fingerprint(fingerprint: &mut Sha256, relative_wire: &str) { - update_fingerprint_field(fingerprint, b"directory"); - update_fingerprint_field(fingerprint, relative_wire.as_bytes()); -} - -fn update_file_source_fingerprint( - fingerprint: &mut Sha256, - relative_wire: &str, - metadata: &fs::Metadata, - executable: bool, -) -> Result<()> { - update_fingerprint_field(fingerprint, b"file"); - update_fingerprint_field(fingerprint, relative_wire.as_bytes()); - fingerprint.update(metadata.len().to_le_bytes()); - fingerprint.update([u8::from(executable)]); - update_platform_file_fingerprint(fingerprint, metadata) -} - -#[cfg(unix)] -fn update_platform_file_fingerprint( - fingerprint: &mut Sha256, - metadata: &fs::Metadata, -) -> Result<()> { - use std::os::unix::fs::MetadataExt; - fingerprint.update(metadata.dev().to_le_bytes()); - fingerprint.update(metadata.ino().to_le_bytes()); - fingerprint.update(metadata.mode().to_le_bytes()); - fingerprint.update(metadata.mtime().to_le_bytes()); - fingerprint.update(metadata.mtime_nsec().to_le_bytes()); - fingerprint.update(metadata.ctime().to_le_bytes()); - fingerprint.update(metadata.ctime_nsec().to_le_bytes()); - Ok(()) -} - -#[cfg(windows)] -fn update_platform_file_fingerprint( - fingerprint: &mut Sha256, - metadata: &fs::Metadata, -) -> Result<()> { - use std::os::windows::fs::MetadataExt; - fingerprint.update(metadata.file_attributes().to_le_bytes()); - fingerprint.update(metadata.creation_time().to_le_bytes()); - fingerprint.update(metadata.last_write_time().to_le_bytes()); - fingerprint.update(metadata.file_size().to_le_bytes()); - Ok(()) -} - -#[cfg(not(any(unix, windows)))] -fn update_platform_file_fingerprint( - fingerprint: &mut Sha256, - metadata: &fs::Metadata, -) -> Result<()> { - use std::time::UNIX_EPOCH; - let modified = metadata - .modified() - .context("read workspace file modification time")?; - let (before_epoch, duration) = match modified.duration_since(UNIX_EPOCH) { - Ok(duration) => (false, duration), - Err(error) => (true, error.duration()), - }; - fingerprint.update([u8::from(before_epoch)]); - fingerprint.update(duration.as_secs().to_le_bytes()); - fingerprint.update(duration.subsec_nanos().to_le_bytes()); - fingerprint.update([u8::from(metadata.permissions().readonly())]); - Ok(()) -} - -fn update_fingerprint_field(fingerprint: &mut Sha256, value: &[u8]) { - fingerprint.update((value.len() as u64).to_le_bytes()); - fingerprint.update(value); -} - -fn finish_source_fingerprint(fingerprint: Sha256) -> WorkspaceSnapshotSourceFingerprint { - WorkspaceSnapshotSourceFingerprint { - format_version: WORKSPACE_SNAPSHOT_SOURCE_FINGERPRINT_VERSION, - sha256: format!("{:x}", fingerprint.finalize()), - } -} - -/// Verify and extract a snapshot into a brand-new staging directory. -/// -/// Callers publish the directory atomically only after this returns. This -/// function never removes or overwrites an existing destination. -pub fn extract_workspace_snapshot( - archive_path: &Path, - destination: &Path, - expected: &WorkspaceSnapshotMetadata, -) -> Result { - if expected.format_version != WORKSPACE_SNAPSHOT_FORMAT_VERSION { - bail!( - "unsupported workspace snapshot format {}; target requires {}", - expected.format_version, - WORKSPACE_SNAPSHOT_FORMAT_VERSION - ); - } - let archive_metadata = fs::symlink_metadata(archive_path) - .with_context(|| format!("inspect workspace archive {}", archive_path.display()))?; - if archive_metadata.file_type().is_symlink() || !archive_metadata.is_file() { - bail!("workspace snapshot archive is not a regular file"); - } - if archive_metadata.len() != expected.archive_size { - bail!( - "workspace snapshot archive size mismatch: expected {}, received {}", - expected.archive_size, - archive_metadata.len() - ); - } - if archive_metadata.len() > MAX_SNAPSHOT_ARCHIVE_BYTES { - bail!("workspace snapshot archive exceeds the target safety limit"); - } - let actual_archive_sha256 = sha256_file(archive_path)?; - if !actual_archive_sha256.eq_ignore_ascii_case(&expected.archive_sha256) { - bail!("workspace snapshot archive SHA-256 mismatch"); - } - match fs::symlink_metadata(destination) { - Ok(_) => bail!( - "workspace snapshot destination already exists: {}", - destination.display() - ), - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("inspect snapshot destination {}", destination.display())) - } - } - fs::create_dir_all(destination) - .with_context(|| format!("create snapshot destination {}", destination.display()))?; - set_private_directory_permissions(destination)?; - - let result = extract_workspace_snapshot_inner(archive_path, destination, expected); - if result.is_err() { - let _ = fs::remove_dir_all(destination); - } - result -} - -fn extract_workspace_snapshot_inner( - archive_path: &Path, - destination: &Path, - expected: &WorkspaceSnapshotMetadata, -) -> Result { - let decoder = GzDecoder::new( - File::open(archive_path) - .with_context(|| format!("open workspace archive {}", archive_path.display()))?, - ); - let mut archive = Archive::new(decoder); - let mut actual_entries: BTreeMap = BTreeMap::new(); - let mut seen_archive_paths = HashSet::new(); - let mut manifest_bytes: Option> = None; - let mut file_count = 0_u64; - let mut directory_count = 0_u64; - let mut uncompressed_bytes = 0_u64; - - for entry in archive - .entries() - .context("read workspace snapshot archive")? - { - let mut entry = entry.context("read workspace snapshot entry")?; - let entry_path = entry - .path() - .context("decode workspace snapshot path")? - .into_owned(); - let entry_wire = portable_relative_path(&entry_path)?; - if !seen_archive_paths.insert(entry_wire.clone()) { - bail!("workspace snapshot contains duplicate entry '{entry_wire}'"); - } - if entry_wire == MANIFEST_ARCHIVE_PATH { - if entry.header().entry_type() != EntryType::Regular { - bail!("workspace snapshot manifest is not a regular file"); - } - if entry.size() > MAX_MANIFEST_BYTES { - bail!("workspace snapshot manifest exceeds the safety limit"); - } - let mut bytes = Vec::with_capacity(entry.size() as usize); - entry - .read_to_end(&mut bytes) - .context("read workspace snapshot manifest")?; - manifest_bytes = Some(bytes); - continue; - } - - let relative = entry_path - .strip_prefix(WORKSPACE_ARCHIVE_ROOT) - .with_context(|| format!("unexpected workspace snapshot entry '{entry_wire}'"))?; - let relative_wire = portable_relative_path(relative)?; - if relative_wire.is_empty() { - bail!("workspace snapshot contains an empty workspace entry"); - } - if contains_git_metadata(relative) { - bail!("workspace snapshot contains forbidden Git metadata '{relative_wire}'"); - } - if let Some(parent) = relative - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - let parent_wire = portable_relative_path(parent)?; - if !matches!( - actual_entries.get(&parent_wire).map(|entry| entry.kind), - Some(WorkspaceSnapshotEntryKind::Directory) - ) { - bail!( - "workspace snapshot entry '{}' appears before its declared parent directory '{}'", - relative_wire, - parent_wire - ); - } - } - let output_path = destination.join(relative); - ensure_path_below(destination, &output_path)?; - match entry.header().entry_type() { - EntryType::Directory => { - directory_count = directory_count.saturating_add(1); - if directory_count > MAX_SNAPSHOT_DIRECTORIES { - bail!("workspace snapshot exceeds the target directory limit"); - } - fs::create_dir_all(&output_path).with_context(|| { - format!("create snapshot directory {}", output_path.display()) - })?; - set_private_directory_permissions(&output_path)?; - actual_entries.insert( - relative_wire.clone(), - WorkspaceSnapshotEntry { - path: relative_wire, - kind: WorkspaceSnapshotEntryKind::Directory, - size: 0, - sha256: None, - executable: false, - }, - ); - } - EntryType::Regular => { - let size = entry.size(); - if size > MAX_SNAPSHOT_FILE_BYTES { - bail!("workspace snapshot file '{relative_wire}' exceeds the target limit"); - } - file_count = file_count.saturating_add(1); - if file_count > MAX_SNAPSHOT_FILES { - bail!("workspace snapshot exceeds the target file limit"); - } - uncompressed_bytes = uncompressed_bytes.saturating_add(size); - if uncompressed_bytes > MAX_SNAPSHOT_UNCOMPRESSED_BYTES { - bail!("workspace snapshot exceeds the target uncompressed limit"); - } - if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("create snapshot parent {}", parent.display()))?; - set_private_directory_permissions(parent)?; - } - let mut output = OpenOptions::new() - .write(true) - .create_new(true) - .open(&output_path) - .with_context(|| format!("create snapshot file {}", output_path.display()))?; - set_private_file_permissions(&output_path)?; - let mut hashing = HashingReader::new(&mut entry); - io::copy(&mut hashing, &mut output) - .with_context(|| format!("extract snapshot file '{relative_wire}'"))?; - let (actual_size, sha256) = hashing.finish(); - if actual_size != size { - bail!( - "workspace snapshot file '{}' size mismatch: expected {}, extracted {}", - relative_wire, - size, - actual_size - ); - } - output - .sync_all() - .with_context(|| format!("sync snapshot file {}", output_path.display()))?; - let executable = entry.header().mode().unwrap_or(0) & 0o111 != 0; - set_snapshot_file_permissions(&output_path, executable)?; - actual_entries.insert( - relative_wire.clone(), - WorkspaceSnapshotEntry { - path: relative_wire, - kind: WorkspaceSnapshotEntryKind::File, - size, - sha256: Some(sha256), - executable, - }, - ); - } - other => bail!( - "workspace snapshot entry '{}' has unsupported archive type {:?}", - relative_wire, - other - ), - } - } - - let manifest_bytes = - manifest_bytes.ok_or_else(|| anyhow!("workspace snapshot manifest is missing"))?; - if !sha256_bytes(&manifest_bytes).eq_ignore_ascii_case(&expected.manifest_sha256) { - bail!("workspace snapshot manifest SHA-256 mismatch"); - } - let manifest: WorkspaceSnapshotManifest = - serde_json::from_slice(&manifest_bytes).context("decode workspace snapshot manifest")?; - validate_manifest(&manifest, expected)?; - let expected_entries = manifest - .entries - .iter() - .map(|entry| (entry.path.clone(), entry.clone())) - .collect::>(); - if expected_entries.len() != manifest.entries.len() { - bail!("workspace snapshot manifest contains duplicate paths"); - } - if expected_entries != actual_entries { - bail!("workspace snapshot contents do not match the signed manifest"); - } - sync_directory(destination)?; - Ok(manifest) -} - -fn validate_manifest( - manifest: &WorkspaceSnapshotManifest, - expected: &WorkspaceSnapshotMetadata, -) -> Result<()> { - let compatible_capture_mode = match manifest.mode.as_str() { - "exact" => manifest.includes_ignored_files, - "source" => !manifest.includes_ignored_files, - _ => false, - }; - if manifest.format_version != WORKSPACE_SNAPSHOT_FORMAT_VERSION - || !compatible_capture_mode - || !manifest.excludes_git_metadata - { - bail!("workspace snapshot manifest contract is incompatible"); - } - if manifest.file_count != expected.file_count - || manifest.directory_count != expected.directory_count - || manifest.uncompressed_bytes != expected.uncompressed_bytes - { - bail!("workspace snapshot manifest summary does not match upload metadata"); - } - if manifest.file_count > MAX_SNAPSHOT_FILES - || manifest.directory_count > MAX_SNAPSHOT_DIRECTORIES - || manifest.uncompressed_bytes > MAX_SNAPSHOT_UNCOMPRESSED_BYTES - { - bail!("workspace snapshot manifest exceeds target safety limits"); - } - let mut entry_file_count = 0_u64; - let mut entry_directory_count = 0_u64; - let mut entry_uncompressed_bytes = 0_u64; - for entry in &manifest.entries { - let path = Path::new(&entry.path); - if entry.path.is_empty() - || portable_relative_path(path)? != entry.path - || contains_git_metadata(path) - { - bail!( - "workspace snapshot manifest contains invalid path '{}'", - entry.path - ); - } - match entry.kind { - WorkspaceSnapshotEntryKind::File => { - entry_file_count = entry_file_count.saturating_add(1); - entry_uncompressed_bytes = entry_uncompressed_bytes.saturating_add(entry.size); - if entry.size > MAX_SNAPSHOT_FILE_BYTES { - bail!("workspace snapshot manifest contains an oversized file"); - } - if entry - .sha256 - .as_deref() - .is_none_or(|hash| !valid_sha256(hash)) - { - bail!("workspace snapshot manifest has an invalid file digest"); - } - } - WorkspaceSnapshotEntryKind::Directory => { - entry_directory_count = entry_directory_count.saturating_add(1); - if entry.size != 0 || entry.sha256.is_some() || entry.executable { - bail!("workspace snapshot manifest has invalid directory metadata"); - } - } - } - } - if entry_file_count != manifest.file_count - || entry_directory_count != manifest.directory_count - || entry_uncompressed_bytes != manifest.uncompressed_bytes - { - bail!("workspace snapshot manifest summary does not match its entries"); - } - Ok(()) -} - -fn append_directory(archive: &mut Builder, relative_wire: &str) -> Result<()> { - let archive_path = format!("{WORKSPACE_ARCHIVE_ROOT}/{relative_wire}"); - let mut header = safe_header(0, 0o755, EntryType::Directory); - archive - .append_data(&mut header, &archive_path, io::empty()) - .with_context(|| format!("append snapshot directory '{relative_wire}'")) -} - -fn append_file( - archive: &mut Builder, - path: &Path, - relative_wire: &str, - before: &fs::Metadata, - executable: bool, -) -> Result { - let mut input = - File::open(path).with_context(|| format!("open snapshot file {}", path.display()))?; - let mut hashing = HashingReader::new(&mut input); - let archive_path = format!("{WORKSPACE_ARCHIVE_ROOT}/{relative_wire}"); - let mut header = safe_header( - before.len(), - if executable { 0o755 } else { 0o644 }, - EntryType::Regular, - ); - archive - .append_data(&mut header, &archive_path, &mut hashing) - .with_context(|| format!("append snapshot file '{relative_wire}'"))?; - let (read_size, sha256) = hashing.finish(); - if read_size != before.len() { - bail!( - "workspace file '{}' changed size while the snapshot was being created", - relative_wire - ); - } - let after = fs::symlink_metadata(path) - .with_context(|| format!("reinspect snapshot file {}", path.display()))?; - if !after.is_file() - || after.len() != before.len() - || after.modified().ok() != before.modified().ok() - { - bail!( - "workspace file '{}' changed while the snapshot was being created", - relative_wire - ); - } - Ok(sha256) -} - -fn append_bytes( - archive: &mut Builder, - path: &str, - bytes: &[u8], - executable: bool, -) -> Result<()> { - let mut header = safe_header( - bytes.len() as u64, - if executable { 0o755 } else { 0o600 }, - EntryType::Regular, - ); - archive - .append_data(&mut header, path, bytes) - .with_context(|| format!("append snapshot metadata '{path}'")) -} - -fn safe_header(size: u64, mode: u32, entry_type: EntryType) -> Header { - let mut header = Header::new_gnu(); - header.set_size(size); - header.set_mode(mode); - header.set_mtime(0); - header.set_uid(0); - header.set_gid(0); - header.set_entry_type(entry_type); - header -} - -fn portable_relative_path(path: &Path) -> Result { - let mut parts = Vec::new(); - for component in path.components() { - match component { - Component::Normal(part) => { - let part = part.to_str().ok_or_else(|| { - anyhow!( - "workspace snapshot path is not portable UTF-8: {}", - path.display() - ) - })?; - if part.is_empty() || part == "." || part == ".." { - bail!("workspace snapshot path is unsafe: {}", path.display()); - } - parts.push(part); - } - _ => bail!( - "workspace snapshot path is not relative: {}", - path.display() - ), - } - } - Ok(parts.join("/")) -} - -fn ensure_path_below(root: &Path, path: &Path) -> Result<()> { - if path == root || !path.starts_with(root) { - bail!( - "workspace snapshot entry escapes target directory: {}", - path.display() - ); - } - Ok(()) -} - -fn contains_git_metadata(path: &Path) -> bool { - path.components().any(|component| { - matches!(component, Component::Normal(part) if part == std::ffi::OsStr::new(".git")) - }) -} - -fn valid_sha256(value: &str) -> bool { - value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) -} - -/// Digest of an in-memory buffer, the counterpart of [`sha256_file`]. +/// Digest of an in-memory Git bundle or Relay chunk assembly. pub fn sha256_bytes(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } +/// Stream a file into SHA-256 without loading a potentially large bundle into +/// memory. pub fn sha256_file(path: &Path) -> Result { let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?; let mut digest = Sha256::new(); @@ -1442,636 +35,20 @@ pub fn sha256_file(path: &Path) -> Result { Ok(format!("{:x}", digest.finalize())) } -struct HashingReader { - inner: R, - digest: Sha256, - bytes: u64, -} - -impl HashingReader { - fn new(inner: R) -> Self { - Self { - inner, - digest: Sha256::new(), - bytes: 0, - } - } - - fn finish(self) -> (u64, String) { - (self.bytes, format!("{:x}", self.digest.finalize())) - } -} - -impl Read for HashingReader { - fn read(&mut self, buffer: &mut [u8]) -> io::Result { - let read = self.inner.read(buffer)?; - self.digest.update(&buffer[..read]); - self.bytes = self.bytes.saturating_add(read as u64); - Ok(read) - } -} - -#[cfg(unix)] -fn is_executable(metadata: &fs::Metadata) -> bool { - use std::os::unix::fs::PermissionsExt; - metadata.permissions().mode() & 0o111 != 0 -} - -#[cfg(not(unix))] -fn is_executable(_metadata: &fs::Metadata) -> bool { - false -} - -#[cfg(unix)] -fn set_private_file_permissions(path: &Path) -> Result<()> { - 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))] -fn set_private_file_permissions(_path: &Path) -> Result<()> { - Ok(()) -} - -#[cfg(unix)] -fn set_snapshot_file_permissions(path: &Path, executable: bool) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions( - path, - fs::Permissions::from_mode(if executable { 0o700 } else { 0o600 }), - ) - .with_context(|| format!("set snapshot permissions on {}", path.display())) -} - -#[cfg(not(unix))] -fn set_snapshot_file_permissions(_path: &Path, _executable: bool) -> Result<()> { - Ok(()) -} - -#[cfg(unix)] -fn set_private_directory_permissions(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o700)) - .with_context(|| format!("set private permissions on {}", path.display())) -} - -#[cfg(not(unix))] -fn set_private_directory_permissions(_path: &Path) -> Result<()> { - Ok(()) -} - -fn sync_directory(path: &Path) -> Result<()> { - #[cfg(unix)] - { - File::open(path) - .with_context(|| format!("open directory {}", path.display()))? - .sync_all() - .with_context(|| format!("sync directory {}", path.display()))?; - } - #[cfg(not(unix))] - let _ = path; - Ok(()) -} - #[cfg(test)] mod tests { use super::*; #[test] - fn exact_snapshot_round_trips_hidden_and_ignored_files_without_git_metadata() { - let temp = tempfile::tempdir().expect("tempdir"); - let source = temp.path().join("source"); - fs::create_dir_all(source.join("empty")).expect("empty directory"); - fs::create_dir_all(source.join(".git/objects")).expect("git metadata"); - fs::write(source.join("visible.txt"), b"visible").expect("visible"); - fs::write(source.join(".env"), b"SECRET=test").expect("ignored-like file"); - fs::write(source.join(".git/config"), b"credential=never").expect("git config"); - let archive = temp.path().join("snapshot.tar.gz"); - - let metadata = create_exact_workspace_snapshot(&source, &archive).expect("create snapshot"); - assert_eq!(metadata.file_count, 2); - let destination = temp.path().join("destination"); - let manifest = - extract_workspace_snapshot(&archive, &destination, &metadata).expect("extract"); - - assert_eq!( - fs::read(destination.join("visible.txt")).expect("visible output"), - b"visible" - ); - assert_eq!( - fs::read(destination.join(".env")).expect("hidden output"), - b"SECRET=test" - ); - assert!(destination.join("empty").is_dir()); - assert!(!destination.join(".git").exists()); - assert!(manifest.includes_ignored_files); - assert!(manifest.excludes_git_metadata); - } - - #[test] - fn source_snapshot_keeps_hidden_source_and_excludes_ignored_build_output() { - let temp = tempfile::tempdir().expect("tempdir"); - let source = temp.path().join("source"); - fs::create_dir_all(source.join(".git")).expect("repository marker"); - fs::create_dir_all(source.join(".github/workflows")).expect("hidden source directory"); - fs::create_dir_all(source.join("target/debug")).expect("ignored build directory"); - fs::write(source.join(".gitignore"), b"target/\n.env\n").expect("gitignore"); - fs::write(source.join(".github/workflows/check.yml"), b"name: check") - .expect("hidden source file"); - fs::write(source.join("source.rs"), b"fn main() {}").expect("source"); - fs::write(source.join(".env"), b"SECRET=test").expect("ignored secret"); - fs::write(source.join("target/debug/app"), b"build output").expect("build output"); - let archive = temp.path().join("source-snapshot.tar.gz"); - - let metadata = - create_source_workspace_snapshot(&source, &archive).expect("create source snapshot"); - let destination = temp.path().join("destination"); - let manifest = - extract_workspace_snapshot(&archive, &destination, &metadata).expect("extract"); - - assert_eq!(manifest.mode, "exact"); - assert!(manifest.includes_ignored_files); - assert_eq!( - fs::read(destination.join(".github/workflows/check.yml")).expect("workflow"), - b"name: check" - ); - assert!(destination.join("source.rs").is_file()); - assert!(destination.join(".gitignore").is_file()); - assert!(!destination.join(".env").exists()); - assert!(!destination.join("target").exists()); - } - - #[test] - fn source_fingerprint_reuses_ignored_state_and_invalidates_included_changes() { - let temp = tempfile::tempdir().expect("tempdir"); - let source = temp.path().join("source"); - fs::create_dir_all(source.join(".git")).expect("repository marker"); - fs::create_dir_all(source.join("target")).expect("ignored directory"); - fs::write(source.join(".gitignore"), b"target/\n").expect("gitignore"); - fs::write(source.join("source.rs"), b"fn main() {}").expect("source"); - fs::write(source.join("target/app"), b"first build").expect("ignored output"); - let archive = temp.path().join("source-snapshot.tar.gz"); - - let prepared = - prepare_source_workspace_snapshot(&source, &archive).expect("prepare source snapshot"); - let unchanged = - source_workspace_snapshot_source_fingerprint(&source).expect("source fingerprint"); - assert_eq!(prepared.source_fingerprint, unchanged); - - fs::write(source.join("target/app"), b"a different ignored build") - .expect("change ignored output"); - assert_eq!( - unchanged, - source_workspace_snapshot_source_fingerprint(&source) - .expect("fingerprint after ignored change") - ); - - let exact_before = - exact_workspace_snapshot_source_fingerprint(&source).expect("exact fingerprint"); - fs::write( - source.join("target/app"), - b"another ignored build with a different size", - ) - .expect("change exact input"); - assert_ne!( - exact_before, - exact_workspace_snapshot_source_fingerprint(&source) - .expect("exact fingerprint after ignored change") - ); - - fs::write( - source.join("source.rs"), - b"fn main() { println!(\"changed\"); }", - ) - .expect("change source"); - assert_ne!( - unchanged, - source_workspace_snapshot_source_fingerprint(&source) - .expect("fingerprint after source change") - ); - } - - /// The manifest comparison is the second opinion the source fingerprint - /// cannot give: it must forgive metadata churn while still catching every - /// real difference in the captured set. - #[test] - fn manifest_comparison_separates_metadata_churn_from_content_changes() { - let temp = tempfile::tempdir().expect("tempdir"); - let source = temp.path().join("source"); - fs::create_dir_all(source.join(".git")).expect("repository marker"); - fs::create_dir_all(source.join("nested")).expect("nested"); - fs::write(source.join(".gitignore"), b"target/\n").expect("gitignore"); - fs::write(source.join("keep.txt"), b"unchanged").expect("keep"); - fs::write(source.join("nested/deep.txt"), b"deep").expect("deep"); - let archive = temp.path().join("snapshot.tar.gz"); - let prepared = - prepare_source_workspace_snapshot(&source, &archive).expect("prepare snapshot"); - let manifest = &prepared.manifest; - - assert!( - source_workspace_matches_manifest(&source, manifest).expect("compare untouched"), - "an untouched tree must match its own manifest" - ); - - // Rewriting identical bytes changes mtime (and ctime) but nothing the - // archive would contain. - fs::write(source.join("keep.txt"), b"unchanged").expect("rewrite identical bytes"); - assert_ne!( - prepared.source_fingerprint, - source_workspace_snapshot_source_fingerprint(&source).expect("fingerprint"), - "the cheap fingerprint is expected to report this as a change" - ); - assert!( - source_workspace_matches_manifest(&source, manifest).expect("compare after rewrite"), - "identical bytes must still match the manifest" - ); - - // An ignored path is outside the captured set entirely. - fs::create_dir_all(source.join("target")).expect("ignored directory"); - fs::write(source.join("target/app"), b"build output").expect("ignored output"); - assert!( - source_workspace_matches_manifest(&source, manifest).expect("compare ignored addition"), - "an ignored addition is not part of the captured set" - ); - - // Same length, different bytes: only the content pass can see this. - fs::write(source.join("keep.txt"), b"unchangeD").expect("same-size edit"); - assert!( - !source_workspace_matches_manifest(&source, manifest).expect("compare same-size edit"), - "a same-size content change must not match" - ); - fs::write(source.join("keep.txt"), b"unchanged").expect("restore"); - - fs::write(source.join("added.txt"), b"new").expect("added"); - assert!( - !source_workspace_matches_manifest(&source, manifest).expect("compare addition"), - "an added file must not match" - ); - fs::remove_file(source.join("added.txt")).expect("undo addition"); - - fs::remove_file(source.join("nested/deep.txt")).expect("deleted"); - assert!( - !source_workspace_matches_manifest(&source, manifest).expect("compare deletion"), - "a deleted file must not match" - ); - } - - /// Packaging refuses symlinks. The comparison must not quietly approve a - /// tree that packaging would reject; it reports "no match" and lets the - /// repack produce the real diagnostic. - #[cfg(unix)] - #[test] - fn manifest_comparison_rejects_a_path_that_became_a_symlink() { - let temp = tempfile::tempdir().expect("tempdir"); - let source = temp.path().join("source"); - fs::create_dir_all(&source).expect("source"); - fs::write(source.join("real.txt"), b"payload").expect("real"); - let archive = temp.path().join("snapshot.tar.gz"); - let prepared = - prepare_exact_workspace_snapshot(&source, &archive).expect("prepare snapshot"); - - fs::write(temp.path().join("outside.txt"), b"payload").expect("outside"); - fs::remove_file(source.join("real.txt")).expect("remove real file"); - std::os::unix::fs::symlink(temp.path().join("outside.txt"), source.join("real.txt")) - .expect("symlink"); - - assert!( - !exact_workspace_matches_manifest(&source, &prepared.manifest) - .expect("compare symlinked path"), - "a path that became a symlink must not be reported as a match" - ); - } - - #[test] - fn result_bundle_reports_adds_edits_and_deletes_without_git() { - let temp = tempfile::tempdir().expect("tempdir"); - let source = temp.path().join("source"); - fs::create_dir_all(source.join("nested")).expect("nested"); - fs::write(source.join("keep.txt"), b"unchanged").expect("keep"); - fs::write(source.join("edit.txt"), b"before").expect("edit"); - fs::write(source.join("gone.txt"), b"remove me").expect("gone"); - fs::write(source.join("nested/deep.txt"), b"deep").expect("deep"); - let archive = temp.path().join("snapshot.tar.gz"); - let metadata = create_exact_workspace_snapshot(&source, &archive).expect("snapshot"); - let target = temp.path().join("current"); - let baseline = extract_workspace_snapshot(&archive, &target, &metadata).expect("extract"); - - // Stand in for what the agent did on the target. - fs::write(target.join("edit.txt"), b"after").expect("modify"); - fs::remove_file(target.join("gone.txt")).expect("delete"); - fs::write(target.join("new.txt"), b"created").expect("add"); - // A rewrite with identical bytes must not count as a change. - fs::write(target.join("keep.txt"), b"unchanged").expect("rewrite"); - - let bundle = temp.path().join("result.tar.gz"); - let summary = - create_workspace_result_bundle(&target, &baseline, &bundle).expect("result bundle"); - - assert_eq!(summary.added, vec!["new.txt".to_string()]); - assert_eq!(summary.modified, vec!["edit.txt".to_string()]); - assert_eq!(summary.deleted, vec!["gone.txt".to_string()]); - assert!(!summary.is_empty()); - assert_eq!(summary.archive_sha256.len(), 64); - assert!(summary.archive_size > 0); - - // Only changed content travels back; untouched files are not resent. - let listed = list_archive_paths(&bundle); - assert!(listed.contains(&"new.txt".to_string())); - assert!(listed.contains(&"edit.txt".to_string())); - assert!( - !listed.contains(&"keep.txt".to_string()), - "unchanged files must not be included: {listed:?}" - ); - assert!(!listed.contains(&"nested/deep.txt".to_string())); - } - - #[test] - fn an_untouched_workspace_produces_an_empty_result() { - let temp = tempfile::tempdir().expect("tempdir"); - let source = temp.path().join("source"); - fs::create_dir_all(&source).expect("source"); - fs::write(source.join("a.txt"), b"a").expect("a"); - let archive = temp.path().join("snapshot.tar.gz"); - let metadata = create_exact_workspace_snapshot(&source, &archive).expect("snapshot"); - let target = temp.path().join("current"); - let baseline = extract_workspace_snapshot(&archive, &target, &metadata).expect("extract"); - - let bundle = temp.path().join("result.tar.gz"); - let summary = - create_workspace_result_bundle(&target, &baseline, &bundle).expect("result bundle"); - assert!( - summary.is_empty(), - "a job that changed nothing must not report changes: {summary:?}" - ); - } - - /// Snapshot a workspace, mutate the "target" copy, and bundle the result. - fn snapshot_and_diff( - temp: &Path, - seed: &[(&str, &[u8])], - mutate: impl FnOnce(&Path), - ) -> ( - WorkspaceResultSummary, - std::path::PathBuf, - std::path::PathBuf, - ) { - let source = temp.join("source"); - fs::create_dir_all(&source).expect("source"); - for (name, bytes) in seed { - fs::write(source.join(name), bytes).expect("seed file"); - } - let archive = temp.join("snapshot.tar.gz"); - let metadata = create_exact_workspace_snapshot(&source, &archive).expect("snapshot"); - let target = temp.join("current"); - let baseline = extract_workspace_snapshot(&archive, &target, &metadata).expect("extract"); - mutate(&target); - let bundle = temp.join("result.tar.gz"); - let summary = create_workspace_result_bundle(&target, &baseline, &bundle).expect("bundle"); - // A second extraction stands in for the controller's own copy of S0. - let local = temp.join("local"); - extract_workspace_snapshot(&archive, &local, &metadata).expect("extract local"); - (summary, bundle, local) - } - - #[test] - fn applying_a_result_writes_adds_and_edits_and_removes_deletions() { + fn byte_and_file_digests_match() { let temp = tempfile::tempdir().expect("tempdir"); - let (summary, bundle, local) = snapshot_and_diff( - temp.path(), - &[ - ("keep.txt", b"same"), - ("edit.txt", b"before"), - ("gone.txt", b"bye"), - ], - |target| { - fs::write(target.join("edit.txt"), b"after").expect("edit"); - fs::remove_file(target.join("gone.txt")).expect("delete"); - fs::write(target.join("new.txt"), b"created").expect("add"); - }, - ); + let path = temp.path().join("objects.bundle"); + let bytes = b"Git bundle bytes"; + std::fs::write(&path, bytes).expect("write bundle"); - let outcome = - apply_workspace_result_bundle(&bundle, &local, &summary, false).expect("apply"); - assert!(!outcome.aborted, "an untouched local tree has no conflicts"); - assert!(outcome.conflicts.is_empty()); - assert_eq!(fs::read(local.join("edit.txt")).expect("edit"), b"after"); - assert_eq!(fs::read(local.join("new.txt")).expect("new"), b"created"); - assert!( - !local.join("gone.txt").exists(), - "deletions must be applied" - ); assert_eq!( - fs::read(local.join("keep.txt")).expect("keep"), - b"same", - "untouched files must be left alone" + sha256_file(&path).expect("file digest"), + sha256_bytes(bytes) ); } - - #[test] - fn a_locally_edited_file_blocks_the_apply_instead_of_being_overwritten() { - let temp = tempfile::tempdir().expect("tempdir"); - let (summary, bundle, local) = - snapshot_and_diff(temp.path(), &[("shared.txt", b"before")], |target| { - fs::write(target.join("shared.txt"), b"target edit").expect("edit"); - }); - // The user kept working locally while the job ran. - fs::write(local.join("shared.txt"), b"my local work").expect("local edit"); - - let outcome = - apply_workspace_result_bundle(&bundle, &local, &summary, false).expect("apply"); - assert!(outcome.aborted, "a conflict must stop the apply"); - assert_eq!( - outcome.conflicts, - vec![WorkspaceResultConflict { - path: "shared.txt".to_string(), - reason: WorkspaceResultConflictReason::LocallyModified, - }] - ); - assert!(outcome.written.is_empty() && outcome.removed.is_empty()); - assert_eq!( - fs::read(local.join("shared.txt")).expect("local"), - b"my local work", - "nothing may be written when the apply aborts" - ); - - // The user can still choose the target's version explicitly. - let forced = - apply_workspace_result_bundle(&bundle, &local, &summary, true).expect("forced apply"); - assert!(!forced.aborted); - assert_eq!( - fs::read(local.join("shared.txt")).expect("local"), - b"target edit" - ); - } - - #[test] - fn a_tampered_bundle_is_rejected_before_anything_is_written() { - let temp = tempfile::tempdir().expect("tempdir"); - let (summary, bundle, local) = - snapshot_and_diff(temp.path(), &[("a.txt", b"before")], |target| { - fs::write(target.join("a.txt"), b"after").expect("edit"); - }); - fs::write(&bundle, b"not the bundle you verified").expect("tamper"); - - let error = apply_workspace_result_bundle(&bundle, &local, &summary, false) - .expect_err("a tampered bundle must be refused"); - assert!( - error - .to_string() - .contains("does not match the reported digest"), - "{error}" - ); - assert_eq!(fs::read(local.join("a.txt")).expect("local"), b"before"); - } - - #[test] - fn result_paths_cannot_escape_the_workspace() { - let temp = tempfile::tempdir().expect("tempdir"); - let workspace = temp.path().join("ws"); - fs::create_dir_all(&workspace).expect("workspace"); - for hostile in ["../outside", "a/../../outside", "/etc/passwd", ""] { - assert!( - resolve_workspace_child(&workspace, hostile).is_err(), - "must reject {hostile:?}" - ); - } - assert!(resolve_workspace_child(&workspace, "nested/ok.txt").is_ok()); - } - - fn list_archive_paths(archive_path: &Path) -> Vec { - let file = File::open(archive_path).expect("open bundle"); - let mut archive = Archive::new(GzDecoder::new(file)); - archive - .entries() - .expect("entries") - .map(|entry| { - let entry = entry.expect("entry"); - entry - .path() - .expect("path") - .strip_prefix(WORKSPACE_ARCHIVE_ROOT) - .map(|path| path.to_string_lossy().to_string()) - .unwrap_or_default() - }) - .collect() - } - - #[test] - fn exact_snapshot_round_trips_paths_longer_than_a_legacy_tar_header() { - let temp = tempfile::tempdir().expect("tempdir"); - let source = temp.path().join("source"); - let long_directory = "directory-segment-".repeat(8); - let relative = Path::new(&long_directory).join("long-file-name.txt"); - fs::create_dir_all(source.join(&long_directory)).expect("long directory"); - fs::write(source.join(&relative), b"long path").expect("long path file"); - assert!( - format!("{WORKSPACE_ARCHIVE_ROOT}/{}", relative.to_string_lossy()).len() > 100, - "fixture must require a GNU long-name record" - ); - let archive = temp.path().join("snapshot.tar.gz"); - - let metadata = create_exact_workspace_snapshot(&source, &archive).expect("create snapshot"); - let destination = temp.path().join("destination"); - extract_workspace_snapshot(&archive, &destination, &metadata).expect("extract snapshot"); - - assert_eq!( - fs::read(destination.join(relative)).expect("long path output"), - b"long path" - ); - } - - #[cfg(unix)] - #[test] - fn symbolic_links_fail_instead_of_escaping_or_disappearing() { - use std::os::unix::fs::symlink; - - let temp = tempfile::tempdir().expect("tempdir"); - let source = temp.path().join("source"); - fs::create_dir_all(&source).expect("source"); - fs::write(temp.path().join("outside"), b"private").expect("outside"); - symlink(temp.path().join("outside"), source.join("link")).expect("link"); - let error = create_exact_workspace_snapshot(&source, &temp.path().join("snapshot.tar.gz")) - .expect_err("link must fail"); - assert!(error.to_string().contains("symbolic link")); - } - - #[test] - fn tampered_archive_is_rejected_before_extraction() { - let temp = tempfile::tempdir().expect("tempdir"); - let source = temp.path().join("source"); - fs::create_dir_all(&source).expect("source"); - fs::write(source.join("file"), b"original").expect("file"); - let archive = temp.path().join("snapshot.tar.gz"); - let metadata = create_exact_workspace_snapshot(&source, &archive).expect("create snapshot"); - let mut bytes = fs::read(&archive).expect("archive"); - let last = bytes.len() - 1; - bytes[last] ^= 1; - fs::write(&archive, bytes).expect("tamper"); - - let error = extract_workspace_snapshot(&archive, &temp.path().join("out"), &metadata) - .expect_err("tampering must fail"); - assert!(error.to_string().contains("SHA-256 mismatch")); - assert!(!temp.path().join("out").exists()); - } - - #[test] - fn manifest_rejects_nested_git_metadata() { - let manifest = WorkspaceSnapshotManifest { - format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, - mode: "exact".to_string(), - includes_ignored_files: true, - excludes_git_metadata: true, - file_count: 1, - directory_count: 0, - uncompressed_bytes: 1, - entries: vec![WorkspaceSnapshotEntry { - path: "nested/.git/config".to_string(), - kind: WorkspaceSnapshotEntryKind::File, - size: 1, - sha256: Some("0".repeat(64)), - executable: false, - }], - }; - let metadata = WorkspaceSnapshotMetadata { - format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, - archive_size: 1, - archive_sha256: "0".repeat(64), - manifest_sha256: "0".repeat(64), - file_count: 1, - directory_count: 0, - uncompressed_bytes: 1, - }; - assert!(validate_manifest(&manifest, &metadata).is_err()); - } - - #[test] - fn manifest_summary_must_match_its_entries() { - let manifest = WorkspaceSnapshotManifest { - format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, - mode: "exact".to_string(), - includes_ignored_files: true, - excludes_git_metadata: true, - file_count: 0, - directory_count: 0, - uncompressed_bytes: 0, - entries: vec![WorkspaceSnapshotEntry { - path: "file.txt".to_string(), - kind: WorkspaceSnapshotEntryKind::File, - size: 1, - sha256: Some("0".repeat(64)), - executable: false, - }], - }; - let metadata = WorkspaceSnapshotMetadata { - format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, - archive_size: 1, - archive_sha256: "0".repeat(64), - manifest_sha256: "0".repeat(64), - file_count: 0, - directory_count: 0, - uncompressed_bytes: 0, - }; - assert!(validate_manifest(&manifest, &metadata).is_err()); - } } 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 index 77e465d2a8..fe042837ec 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -5,15 +5,18 @@ //! workspaces, sessions, transcripts, process detachment, supervision, and //! cancellation semantics. //! -//! Installing the CLI is a separate, explicit operation. `probe` never installs -//! anything; `install_cli_start` downloads an official archive locally, verifies -//! its SHA256 sidecar (signed, when the release ships `.sha256.sig`) and the -//! mandatory archive minisign signature, then stages it under the SSH user's -//! home before starting an owner-only installer. +//! `probe` is read-only. Submission may automatically install a matching +//! prebuilt release when the target is missing a compatible CLI; +//! `install_cli_start` still verifies the signed SHA256 sidecar and mandatory +//! archive minisign signature before staging an owner-only installer. Source +//! builds remain a separate, explicitly confirmed operation. use anyhow::{anyhow, Context, Result}; +use base64::Engine as _; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::future::Future; use std::time::Duration; use super::manager::SSHConnectionManager; @@ -32,16 +35,48 @@ 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 WORKSPACE_OPERATION_WAIT: Duration = Duration::from_secs(30 * 60); +const WORKSPACE_OPERATION_POLL_INTERVAL: Duration = Duration::from_millis(750); /// A release archive is tens of megabytes and the target's uplink is unknown, /// so this is far longer than an ordinary setup command. const TARGET_DOWNLOAD_TIMEOUT_MS: u64 = 10 * 60 * 1000; -const WORKSPACE_COMMIT_POLL_INTERVAL: Duration = Duration::from_millis(750); -const WORKSPACE_COMMIT_WAIT: Duration = Duration::from_secs(15 * 60); +const CLI_INSTALL_POLL_INTERVAL: Duration = Duration::from_millis(750); +/// A source-free release install is a download plus an unpack; anything past +/// this is a hung target rather than a slow one. +const CLI_INSTALL_WAIT: Duration = Duration::from_secs(15 * 60); const RELEASE_READ_TIMEOUT_SECONDS: u64 = 30; const MAX_ARCHIVE_BYTES: usize = 512 * 1024 * 1024; -/// A result bundle carries only changed files, so it is bounded well below a -/// full workspace snapshot. +/// A result bundle carries only commits since the dispatch baseline, so it is +/// bounded well below a full repository clone in the usual case. const MAX_RESULT_BUNDLE_BYTES: u64 = 1024 * 1024 * 1024; +const RESULT_BUNDLE_CHUNK_BYTES: u64 = 256 * 1024; +const MAX_RESULT_BUNDLE_CHUNK_BASE64_BYTES: usize = 384 * 1024; + +struct UnverifiedResultBundle { + path: std::path::PathBuf, + verified: bool, +} + +impl UnverifiedResultBundle { + fn new(path: &std::path::Path) -> Self { + Self { + path: path.to_path_buf(), + verified: false, + } + } + + fn retain(&mut self) { + self.verified = true; + } +} + +impl Drop for UnverifiedResultBundle { + fn drop(&mut self) { + if !self.verified { + let _ = std::fs::remove_file(&self.path); + } + } +} /// Oldest glibc the published Linux binaries run against. Kept in step with /// `scripts/ci/check-glibc-floor.sh`, which enforces it at release time. const GLIBC_FLOOR: &str = "2.35"; @@ -49,7 +84,7 @@ const GLIBC_FLOOR: &str = "2.35"; /// Same figure the relay source build uses. const SOURCE_BUILD_FREE_KB: u64 = 6 * 1024 * 1024; const REPO_GIT_URL: &str = "https://github.com/GCWing/BitFun.git"; -const DISPATCH_PROTOCOL_VERSION: u64 = 2; +const DISPATCH_PROTOCOL_VERSION: u64 = 3; const DISPATCH_WORKER_CLI_PROFILE_CAPABILITY: &str = "dispatch_worker_cli_profile"; /// First stable release whose CLI is known to contain every capability below. /// @@ -57,8 +92,8 @@ const DISPATCH_WORKER_CLI_PROFILE_CAPABILITY: &str = "dispatch_worker_cli_profil /// version is published. In that window `CARGO_PKG_VERSION` still names the /// previous release, so comparing only the installed and controller version /// strings is not a sound compatibility test. -const FIRST_COMPATIBLE_STABLE_DISPATCH_RELEASE: (u64, u64, u64) = (0, 2, 15); -const REQUIRED_DISPATCH_CAPABILITIES: [&str; 13] = [ +const FIRST_COMPATIBLE_STABLE_DISPATCH_RELEASE: (u64, u64, u64) = (0, 2, 16); +const REQUIRED_DISPATCH_CAPABILITIES: [&str; 14] = [ "persistent_jobs", "cursor_events", "detached_worker", @@ -69,8 +104,11 @@ const REQUIRED_DISPATCH_CAPABILITIES: [&str; 13] = [ "approval_remote", "append_message", "event_log_completeness", - "workspace_snapshot_exact", - "workspace_snapshot_chunked", + // Git-worktree delivery. There is no snapshot fallback, so these are hard + // requirements rather than feature-detected extras. + "workspace_git_worktree", + "workspace_git_bundle_upload", + "workspace_git_sync", DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, ]; @@ -472,37 +510,31 @@ pub fn validate_dispatch_protocol(protocol: &Value, approval_policy: Option<&str 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", - DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, - ], - Some("reject-and-report") => &[ - "persistent_jobs", - "cursor_events", - "detached_worker", - "workspace_serialization", - "frontend_event_projection", - "approval_reject_and_report", - DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, - ], - Some("remote") => &[ - "persistent_jobs", - "cursor_events", - "detached_worker", - "workspace_serialization", - "frontend_event_projection", - "approval_remote", - DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, - ], + let mut required = vec![ + "persistent_jobs", + "cursor_events", + "detached_worker", + "workspace_serialization", + "frontend_event_projection", + "workspace_git_worktree", + "workspace_git_bundle_upload", + "workspace_git_sync", + DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, + ]; + match approval_policy { + Some("auto") => &["approval_auto"], + Some("reject-and-report") => &["approval_reject_and_report"], + Some("remote") => &["approval_remote"], Some(_) => return Err(anyhow!("unsupported dispatch approval policy")), - None => &REQUIRED_DISPATCH_CAPABILITIES, - }; + None => REQUIRED_DISPATCH_CAPABILITIES.as_slice(), + } + .iter() + .copied() + .for_each(|capability| { + if !required.contains(&capability) { + required.push(capability); + } + }); let missing = required .iter() .copied() @@ -1353,50 +1385,65 @@ pub async fn append( invoke_json(manager, connection_id, "append", request).await } -/// Ask the target what a finished job changed, and fetch the bundle. +/// Commit the target's worktree and fetch the Git bundle it produced. /// -/// Downloads only; nothing is written into the user's workspace here. Applying -/// the bundle is a separate operation the user confirms after seeing the diff, -/// because the local tree may have moved on since the snapshot was taken. -pub async fn pull_result( +/// Downloads only. The controller decides separately whether to fast-forward +/// its baseline worktree onto the fetched branch, so nothing in the user's +/// repository moves as a side effect of asking. +pub async fn sync_workspace( manager: &SSHConnectionManager, connection_id: &str, job_id: &str, + message: Option<&str>, + known_head: Option<&str>, destination: &std::path::Path, ) -> 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") + anyhow!("BitFun CLI is not installed on the SSH target; install it before syncing") })?; - // Returning results is an optional capability, so a target that predates it - // is a normal situation rather than a fault. Ask before invoking the verb: - // otherwise the only signal is clap's `unrecognized subcommand`, which says - // nothing about what the user should do. - let protocol = invoke_json_at_path( - manager, - connection_id, - &target.home, - cli_path, - "probe", - &serde_json::json!({}), - ) - .await - .context("probe the dispatch target before pulling results")?; - ensure_result_bundle_capability(&protocol)?; + // A clean incremental sync has `headCommit == knownHead`. Without an + // invocation identity, the target cannot distinguish this call's poll + // from a later click that intentionally checks for newer work, and would + // restart the completed no-op forever. + let mut request = serde_json::json!({ + "jobId": job_id, + "operationId": uuid::Uuid::new_v4().as_simple().to_string(), + }); + if let Some(message) = message.map(str::trim).filter(|value| !value.is_empty()) { + request["message"] = Value::String(message.to_string()); + } + if let Some(head) = known_head.map(str::trim).filter(|value| !value.is_empty()) { + request["knownHead"] = Value::String(head.to_string()); + } + let deadline = tokio::time::Instant::now() + WORKSPACE_OPERATION_WAIT; + let response = loop { + let response = invoke_json_at_path( + manager, + connection_id, + &target.home, + cli_path, + "__workspace_sync", + &request, + ) + .await?; + if response.get("pending").and_then(Value::as_bool) != Some(true) { + break response; + } + if tokio::time::Instant::now() >= deadline { + return Err(anyhow!( + "Git workspace sync did not finish within {} minutes", + WORKSPACE_OPERATION_WAIT.as_secs() / 60 + )); + } + tokio::time::sleep(WORKSPACE_OPERATION_POLL_INTERVAL).await; + }; - let response = invoke_json_at_path( - manager, - connection_id, - &target.home, - cli_path, - // The target CLI exposes workspace data-plane verbs under reserved - // names, matching `__workspace_begin` and `__workspace_commit` above. - "__workspace_result", - &serde_json::json!({ "jobId": job_id }), - ) - .await?; + if response.get("changed").and_then(Value::as_bool) != Some(true) { + return Ok(response); + } let bundle_path = response .get("bundlePath") @@ -1405,26 +1452,97 @@ pub async fn pull_result( // The path comes from the target, so bound it to the managed job directory // before reading it, exactly as the upload path is bounded. validate_managed_result_path(&target.home, job_id, bundle_path)?; - - let bytes = manager - .sftp_read(connection_id, bundle_path) - .await - .context("download dispatch result bundle")?; - if bytes.len() as u64 > MAX_RESULT_BUNDLE_BYTES { + let expected_size = response + .get("bundleSize") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("dispatch target returned no result bundle size"))?; + if expected_size == 0 || expected_size > MAX_RESULT_BUNDLE_BYTES { return Err(anyhow!( "dispatch result bundle exceeds the {} MB safety limit", MAX_RESULT_BUNDLE_BYTES / (1024 * 1024) )); } - // The bundle carries the user's source, including the ignored files the - // snapshot deliberately shipped. The outbound root is already owner-only, - // but harden this level too rather than relying on a parent one layer up. + let expected_digest = response + .get("bundleSha256") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("dispatch target returned no result bundle digest"))?; + + // The bundle carries repository history. The outbound root is already + // owner-only, but harden this level too rather than relying on a parent. if let Some(parent) = destination.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("create result staging {}", parent.display()))?; harden_result_directory(parent)?; } - write_private_file(destination, &bytes)?; + let mut staged_bundle = UnverifiedResultBundle::new(destination); + write_private_file(destination, &[])?; + let mut output = std::fs::OpenOptions::new() + .append(true) + .open(destination) + .with_context(|| format!("open result staging {}", destination.display()))?; + let mut digest = Sha256::new(); + let mut received = 0_u64; + while received < expected_size { + let chunk = invoke_json_at_path( + manager, + connection_id, + &target.home, + cli_path, + "__workspace_sync_chunk", + &serde_json::json!({ + "jobId": job_id, + "offset": received, + "length": RESULT_BUNDLE_CHUNK_BYTES, + }), + ) + .await?; + let encoded = chunk + .get("dataBase64") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("dispatch target returned no result chunk data"))?; + if encoded.len() > MAX_RESULT_BUNDLE_CHUNK_BASE64_BYTES { + return Err(anyhow!( + "dispatch target returned an oversized result chunk" + )); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .context("decode dispatch result chunk")?; + if decoded.is_empty() || decoded.len() as u64 > RESULT_BUNDLE_CHUNK_BYTES { + return Err(anyhow!( + "dispatch result bundle ended at {received} of {expected_size} bytes" + )); + } + let next_offset = received.saturating_add(decoded.len() as u64); + if next_offset > expected_size { + return Err(anyhow!( + "dispatch target returned more result bytes than it declared" + )); + } + if chunk.get("offset").and_then(Value::as_u64) != Some(next_offset) { + return Err(anyhow!( + "dispatch target returned a mismatched result chunk offset" + )); + } + std::io::Write::write_all(&mut output, &decoded) + .with_context(|| format!("write result staging {}", destination.display()))?; + digest.update(&decoded); + received = next_offset; + let eof = chunk.get("eof").and_then(Value::as_bool) == Some(true); + if eof != (received == expected_size) { + return Err(anyhow!( + "dispatch target returned an inconsistent result end marker" + )); + } + } + output + .sync_all() + .with_context(|| format!("flush result staging {}", destination.display()))?; + let actual_digest = format!("{:x}", digest.finalize()); + if !actual_digest.eq_ignore_ascii_case(expected_digest) { + return Err(anyhow!("dispatch result bundle SHA-256 mismatch")); + } + staged_bundle.retain(); let mut response = response; if let Some(object) = response.as_object_mut() { @@ -1466,66 +1584,126 @@ pub fn write_private_file(path: &std::path::Path, bytes: &[u8]) -> Result<()> { Ok(()) } -/// Optional capability: a target without it still runs jobs, it just cannot -/// hand their results back. Deliberately absent from -/// `REQUIRED_DISPATCH_CAPABILITIES` so an older CLI stays fully usable. -pub const WORKSPACE_RESULT_CAPABILITY: &str = "workspace_result_bundle"; - -fn ensure_result_bundle_capability(protocol: &Value) -> Result<()> { - let advertises = protocol - .get("capabilities") - .and_then(Value::as_array) - .is_some_and(|capabilities| { - capabilities - .iter() - .any(|capability| capability.as_str() == Some(WORKSPACE_RESULT_CAPABILITY)) - }); - if !advertises { +/// A result bundle may only be read from the managed directory of the job it +/// belongs to. +fn validate_managed_result_path(home: &str, job_id: &str, bundle_path: &str) -> Result<()> { + let expected = format!( + "{}/.bitfun/dispatch/workspaces/{job_id}/result.bundle", + home.trim_end_matches('/') + ); + if bundle_path != expected { return Err(anyhow!( - "this target's BitFun CLI cannot return job results; update it to a release that supports {WORKSPACE_RESULT_CAPABILITY}" + "dispatch target returned an unexpected result bundle path" )); } Ok(()) } -/// A result bundle may only be read from the managed directory of the job it -/// belongs to. -fn validate_managed_result_path(home: &str, job_id: &str, bundle_path: &str) -> Result<()> { +/// The upload path for a delivered base bundle, bounded to the job directory. +fn validate_managed_bundle_upload_path(home: &str, job_id: &str, upload_path: &str) -> Result<()> { let expected = format!( - "{}/.bitfun/dispatch/workspaces/{job_id}/result.tar.gz", + "{}/.bitfun/dispatch/workspaces/{job_id}/incoming.bundle", home.trim_end_matches('/') ); - if bundle_path != expected { + if upload_path != expected { return Err(anyhow!( - "dispatch target returned an unexpected result bundle path" + "dispatch target returned an invalid managed bundle upload path" )); } Ok(()) } -/// Stage and atomically materialize a controller-created workspace snapshot. +/// Ask the target to check out this dispatch's baseline commit. +/// +/// Returns the target's raw response so the controller can react to +/// `needsBundle` — the target is the only side that knows what its own clone +/// can reach, so the decision to ship objects belongs to it, not to a guess +/// made from this machine's remote-tracking refs. +pub async fn provision_workspace( + manager: &SSHConnectionManager, + connection_id: &str, + request: &Value, +) -> Result { + invoke_workspace_operation( + manager, + connection_id, + "__workspace_provision", + request, + "Git workspace provisioning", + ) + .await +} + +/// Poll an idempotent target verb whose expensive Git work runs in a detached +/// CLI child. Every SSH command returns quickly, so losing one channel cannot +/// kill clone/fetch/bundle work that the next poll can observe. +async fn invoke_workspace_operation( + manager: &SSHConnectionManager, + connection_id: &str, + verb: &'static str, + request: &Value, + operation: &str, +) -> 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; install it before dispatching") + })?; + let deadline = tokio::time::Instant::now() + WORKSPACE_OPERATION_WAIT; + loop { + let response = invoke_json_at_path( + manager, + connection_id, + &target.home, + cli_path, + verb, + request, + ) + .await?; + if response.get("pending").and_then(Value::as_bool) != Some(true) { + return Ok(response); + } + if tokio::time::Instant::now() >= deadline { + return Err(anyhow!( + "{operation} did not finish within {} minutes", + WORKSPACE_OPERATION_WAIT.as_secs() / 60 + )); + } + tokio::time::sleep(WORKSPACE_OPERATION_POLL_INTERVAL).await; + } +} + +/// Upload a Git bundle carrying the objects the target reported missing. /// /// The target CLI chooses the owner-only upload path. This adapter validates /// that the returned path stays under the target's managed dispatch root before /// allowing SFTP to write it. -pub async fn upload_workspace_snapshot( +pub async fn upload_bundle( manager: &SSHConnectionManager, connection_id: &str, - begin_request: &Value, - archive_path: &std::path::Path, + job_id: &str, + sha256: &str, + size: u64, + bundle_path: &std::path::Path, ) -> 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") + anyhow!("BitFun CLI is not installed on the SSH target; install it before dispatching") })?; + let begin = invoke_json_at_path( manager, connection_id, &target.home, cli_path, - "__workspace_begin", - begin_request, + "__workspace_bundle_begin", + &serde_json::json!({ + "protocolVersion": DISPATCH_PROTOCOL_VERSION, + "jobId": job_id, + "sha256": sha256, + "size": size, + }), ) .await?; if begin @@ -1536,122 +1714,247 @@ pub async fn upload_workspace_snapshot( return Ok(begin); } if begin.get("accepted").and_then(Value::as_bool) != Some(true) { - return Err(anyhow!( - "dispatch target did not accept the workspace upload" - )); + return Err(anyhow!("dispatch target did not accept the bundle upload")); } - let upload_path = begin - .get("uploadPath") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("dispatch target returned no workspace upload path"))?; - validate_managed_workspace_upload_path(&target.home, upload_path)?; - let archive_size = begin_request - .pointer("/metadata/archiveSize") - .and_then(Value::as_u64) - .ok_or_else(|| anyhow!("workspace upload request has no archiveSize"))?; - let local_size = std::fs::symlink_metadata(archive_path) - .with_context(|| format!("inspect workspace snapshot {}", archive_path.display()))? + + let upload_path = format!( + "{}/.bitfun/dispatch/workspaces/{job_id}/incoming.bundle", + target.home.trim_end_matches('/') + ); + validate_managed_bundle_upload_path(&target.home, job_id, &upload_path)?; + let local_size = std::fs::symlink_metadata(bundle_path) + .with_context(|| format!("inspect dispatch bundle {}", bundle_path.display()))? .len(); - if local_size != archive_size { + if local_size != size { return Err(anyhow!( - "workspace snapshot changed before SSH upload: expected {archive_size} bytes, found {local_size}" + "dispatch bundle changed before SSH upload: expected {size} bytes, found {local_size}" )); } let retained_offset = begin .get("offset") .and_then(Value::as_u64) - .ok_or_else(|| anyhow!("dispatch target returned no workspace upload offset"))?; - if retained_offset > archive_size { + .ok_or_else(|| anyhow!("dispatch target returned no bundle upload offset"))?; + if retained_offset > size { return Err(anyhow!( - "dispatch target returned an invalid workspace upload offset" + "dispatch target returned an invalid bundle upload offset" )); } - if retained_offset < archive_size { + if retained_offset < size { let written = manager - .sftp_write_from_file(connection_id, upload_path, archive_path, archive_size) + .sftp_write_from_file(connection_id, &upload_path, bundle_path, size) .await - .context("upload workspace snapshot over SFTP")?; - if written != archive_size { + .context("upload dispatch bundle over SFTP")?; + if written != size { return Err(anyhow!( - "workspace snapshot SFTP upload ended at {written} of {archive_size} bytes" + "dispatch bundle SFTP upload ended at {written} of {size} bytes" )); } } - let job_id = begin_request - .get("jobId") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("workspace upload request has no jobId"))?; - let expected_digest = begin_request - .pointer("/metadata/archiveSha256") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("workspace upload request has no archiveSha256"))?; - let deadline = tokio::time::Instant::now() + WORKSPACE_COMMIT_WAIT; + + let commit_request = serde_json::json!({ "jobId": job_id }); + let deadline = tokio::time::Instant::now() + WORKSPACE_OPERATION_WAIT; loop { - let committed = invoke_json_at_path( + let response = invoke_json_at_path( manager, connection_id, &target.home, cli_path, - "__workspace_commit", - &serde_json::json!({ "jobId": job_id }), + "__workspace_bundle_commit", + &commit_request, ) .await?; - if committed - .pointer("/metadata/archiveSha256") - .and_then(Value::as_str) - != Some(expected_digest) - { + if response.get("pending").and_then(Value::as_bool) != Some(true) { + if response.get("committed").and_then(Value::as_bool) == Some(true) { + return Ok(response); + } return Err(anyhow!( - "dispatch target returned mismatched workspace snapshot metadata" + "dispatch target did not commit the delivered bundle" )); } - if committed - .get("committed") - .and_then(Value::as_bool) - .unwrap_or(false) - { - if committed - .get("workspacePath") - .and_then(Value::as_str) - .is_none_or(|path| path.trim().is_empty()) - { - return Err(anyhow!( - "dispatch target committed no materialized workspace path" - )); - } - return Ok(committed); - } if tokio::time::Instant::now() >= deadline { return Err(anyhow!( - "dispatch target workspace materialization did not finish within 15 minutes" + "Git bundle import did not finish within {} minutes", + WORKSPACE_OPERATION_WAIT.as_secs() / 60 )); } - tokio::time::sleep(WORKSPACE_COMMIT_POLL_INTERVAL).await; + tokio::time::sleep(WORKSPACE_OPERATION_POLL_INTERVAL).await; } } -fn validate_managed_workspace_upload_path(home: &str, upload_path: &str) -> Result<()> { - let prefix = format!( - "{}/.bitfun/dispatch/workspaces/", - home.trim_end_matches('/') - ); - let Some(relative) = upload_path.strip_prefix(&prefix) else { +/// Make sure the target runs a CLI this controller can dispatch to. +/// +/// Installing is automatic because a dispatch is useless without it and the +/// user already authorized this SSH connection. What the confirmation dialog +/// used to guarantee is preserved by other means: the archive is still verified +/// against a signed SHA-256 and a mandatory minisign signature before it is +/// staged, and every step is reported through `progress` so the install is +/// visible in the dispatch session rather than silent. +/// +/// Source builds are deliberately not automatic. They upload the user's own +/// repository and compile it on the target, which is a different kind of act +/// from fetching a signed release. +pub async fn ensure_target_cli( + manager: &SSHConnectionManager, + connection_id: &str, + mut progress: Progress, +) -> Result +where + Progress: FnMut(&str, Value) -> ProgressFuture, + ProgressFuture: Future>, +{ + let probed = probe(manager, connection_id, None).await?; + if let Some(protocol) = probed.protocol.as_ref() { + if validate_dispatch_protocol(protocol, None).is_ok() { + return Ok(probed); + } + } + if !probed.install_supported { return Err(anyhow!( - "dispatch target returned an upload path outside its managed workspace root" + "{}", + probed + .install_error + .as_deref() + .or(probed.protocol_error.as_deref()) + .unwrap_or("this SSH target cannot run the BitFun CLI") )); - }; - let components = relative.split('/').collect::>(); - if components.len() != 2 - || components[0].is_empty() - || components[0] == "." - || components[0] == ".." - || components[1] != "workspace.tar.gz" - { + } + if let Some(reason) = probed.prebuilt_incompatible.as_deref() { + // A source build needs its own confirmation, so stop here with the + // reason rather than silently escalating to compiling on the target. return Err(anyhow!( - "dispatch target returned an invalid managed workspace upload path" + "no published BitFun CLI can run on this target ({reason}); build it from source explicitly" )); } - Ok(()) + let release = probed.release.clone().ok_or_else(|| { + anyhow!("could not resolve a BitFun CLI release for this target's platform") + })?; + + progress( + "cli-install-started", + serde_json::json!({ + "version": release.version, + "target": release.target, + "url": release.url, + "sha256": release.sha256, + "reason": probed + .protocol_error + .clone() + .unwrap_or_else(|| "the target has no compatible BitFun CLI".to_string()), + }), + ) + .await + .context("persist the CLI install started audit event")?; + if let Err(error) = install_cli_start(manager, connection_id, &release).await { + emit_cli_install_failure(&mut progress, "install-start", &error).await?; + return Err(error); + } + + let deadline = tokio::time::Instant::now() + CLI_INSTALL_WAIT; + let mut cursor = 0_u64; + loop { + let poll = match install_cli_poll(manager, connection_id, cursor).await { + Ok(poll) => poll, + Err(error) => { + emit_cli_install_failure(&mut progress, "install-poll", &error).await?; + return Err(error); + } + }; + cursor = poll.cursor; + match poll.status { + DispatchInstallStatus::Succeeded => break, + DispatchInstallStatus::Failed => { + let error = anyhow!( + "BitFun CLI installation failed on the SSH target: {}", + bounded_detail(&poll.output) + ); + emit_cli_install_failure(&mut progress, "install-status", &error).await?; + return Err(error); + } + _ => {} + } + if tokio::time::Instant::now() >= deadline { + let _ = install_cli_cancel(manager, connection_id).await; + let error = anyhow!( + "BitFun CLI installation did not finish within {} minutes", + CLI_INSTALL_WAIT.as_secs() / 60 + ); + emit_cli_install_failure(&mut progress, "install-timeout", &error).await?; + return Err(error); + } + tokio::time::sleep(CLI_INSTALL_POLL_INTERVAL).await; + } + + let reprobed = match probe(manager, connection_id, None).await { + Ok(probed) => probed, + Err(error) => { + emit_cli_install_failure(&mut progress, "reprobe", &error).await?; + return Err(error); + } + }; + let protocol = match reprobed.protocol.as_ref() { + Some(protocol) => protocol, + None => { + let error = anyhow!( + "{}", + reprobed.protocol_error.as_deref().unwrap_or( + "the installed BitFun CLI still does not answer the dispatch protocol" + ) + ); + emit_cli_install_failure(&mut progress, "protocol-validation", &error).await?; + return Err(error); + } + }; + // Fail closed: an install that "succeeded" but left an incompatible binary + // must not be treated as a usable target. + if let Err(error) = validate_dispatch_protocol(protocol, None) { + emit_cli_install_failure(&mut progress, "protocol-validation", &error).await?; + return Err(error); + } + progress( + "cli-install-succeeded", + serde_json::json!({ + "version": release.version, + "cliPath": reprobed.cli_path, + }), + ) + .await + .context("persist the CLI install succeeded audit event")?; + Ok(reprobed) +} + +async fn emit_cli_install_failure( + progress: &mut Progress, + phase: &str, + error: &anyhow::Error, +) -> Result<()> +where + Progress: FnMut(&str, Value) -> ProgressFuture, + ProgressFuture: Future>, +{ + progress( + "cli-install-failed", + cli_install_failure_details(phase, &error.to_string()), + ) + .await + .map_err(|audit_error| { + anyhow!( + "persist the CLI install failed audit event for phase '{}': {}; original failure: {}", + bounded_detail(phase), + bounded_detail(&audit_error.to_string()), + bounded_detail(&error.to_string()) + ) + }) +} + +fn cli_install_failure_details(phase: &str, error: &str) -> Value { + let error = bounded_detail(error); + serde_json::json!({ + "phase": bounded_detail(phase), + "error": error, + // Keep the existing audit/UI projection useful while `phase` and + // `error` provide the structured durable form. + "output": error, + }) } async fn invoke_json( @@ -2720,36 +3023,17 @@ mod tests { } #[test] - fn a_target_without_the_result_capability_is_told_what_to_do() { - // Optional capability: the failure must name the fix, not surface - // clap's "unrecognized subcommand" from the verb invocation. - let without = serde_json::json!({ - "capabilities": ["persistent_jobs", "cursor_events"] - }); - let error = ensure_result_bundle_capability(&without) - .expect_err("a target that cannot return results must say so"); - assert!( - error.to_string().contains("cannot return job results"), - "{error}" - ); - - let with = serde_json::json!({ - "capabilities": ["persistent_jobs", WORKSPACE_RESULT_CAPABILITY] - }); - assert!(ensure_result_bundle_capability(&with).is_ok()); - - // A malformed probe must fail closed rather than assume support. - assert!(ensure_result_bundle_capability(&serde_json::json!({})).is_err()); - } - - #[test] - fn the_optional_result_capability_is_never_required_for_ordinary_dispatch() { - // Requiring it would make every older target unusable for jobs it can - // still run perfectly well. - assert!( - !REQUIRED_DISPATCH_CAPABILITIES.contains(&WORKSPACE_RESULT_CAPABILITY), - "returning results must stay optional" - ); + fn git_worktree_delivery_capabilities_are_required() { + for capability in [ + "workspace_git_worktree", + "workspace_git_bundle_upload", + "workspace_git_sync", + ] { + assert!( + REQUIRED_DISPATCH_CAPABILITIES.contains(&capability), + "{capability} must fail closed because snapshot delivery no longer exists" + ); + } } #[cfg(unix)] @@ -2799,6 +3083,25 @@ mod tests { assert_eq!(std::fs::read(&bundle).expect("read"), b"short"); } + #[test] + fn unverified_result_bundles_are_removed_but_verified_ones_are_retained() { + let temp = tempfile::tempdir().expect("temp dir"); + let rejected = temp.path().join("rejected.bundle"); + std::fs::write(&rejected, b"tampered").expect("write rejected bundle"); + drop(UnverifiedResultBundle::new(&rejected)); + assert!(!rejected.exists()); + + let accepted = temp.path().join("accepted.bundle"); + std::fs::write(&accepted, b"verified").expect("write accepted bundle"); + let mut guard = UnverifiedResultBundle::new(&accepted); + guard.retain(); + drop(guard); + assert_eq!( + std::fs::read(&accepted).expect("read accepted"), + b"verified" + ); + } + #[test] fn a_result_bundle_is_only_read_from_its_own_managed_directory() { // The path is chosen by the target, so a compromised or buggy one must @@ -2806,12 +3109,12 @@ mod tests { assert!(validate_managed_result_path( "/home/user", "job-1", - "/home/user/.bitfun/dispatch/workspaces/job-1/result.tar.gz" + "/home/user/.bitfun/dispatch/workspaces/job-1/result.bundle" ) .is_ok()); for hostile in [ "/home/user/.ssh/id_ed25519", - "/home/user/.bitfun/dispatch/workspaces/job-2/result.tar.gz", + "/home/user/.bitfun/dispatch/workspaces/job-2/result.bundle", "/home/user/.bitfun/dispatch/workspaces/job-1/../../../.ssh/id_ed25519", "/home/user/.bitfun/dispatch/workspaces/job-1/current/secret", ] { @@ -2974,11 +3277,11 @@ mod tests { #[test] fn release_compatibility_uses_capability_floor_not_installed_version() { assert!( - !published_release_supports_required_dispatch_protocol("0.2.14"), - "the last release without the worker profile must use the exact controller source" + !published_release_supports_required_dispatch_protocol("0.2.15"), + "the last snapshot-delivery release cannot satisfy protocol v3" ); assert!( - published_release_supports_required_dispatch_protocol("0.2.15"), + published_release_supports_required_dispatch_protocol("0.2.16"), "the first compatible stable release must be installable" ); assert!(published_release_supports_required_dispatch_protocol( @@ -3712,6 +4015,32 @@ mod tests { assert!(ensure_confirmed_release(&changed, &confirmed).is_err()); } + #[test] + fn cli_install_failure_audit_details_are_structured_and_bounded() { + let details = cli_install_failure_details(&"p".repeat(700), &"e".repeat(700)); + + assert_eq!( + details + .get("phase") + .and_then(Value::as_str) + .expect("phase") + .chars() + .count(), + 500 + ); + assert_eq!( + details + .get("error") + .and_then(Value::as_str) + .expect("error") + .chars() + .count(), + 500 + ); + assert_eq!(details.get("output"), details.get("error")); + assert_eq!(details.as_object().expect("details").len(), 3); + } + #[test] fn incompatible_dispatch_protocols_require_an_upgrade() { let capabilities = REQUIRED_DISPATCH_CAPABILITIES; @@ -3742,6 +4071,9 @@ mod tests { "workspace_serialization", "frontend_event_projection", "approval_reject_and_report", + "workspace_git_worktree", + "workspace_git_bundle_upload", + "workspace_git_sync", DISPATCH_WORKER_CLI_PROFILE_CAPABILITY ], }); @@ -3757,7 +4089,10 @@ mod tests { "detached_worker", "workspace_serialization", "frontend_event_projection", - "approval_reject_and_report" + "approval_reject_and_report", + "workspace_git_worktree", + "workspace_git_bundle_upload", + "workspace_git_sync" ], }); let error = validate_dispatch_protocol(&unsafe_worker, Some("reject-and-report")) diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss index 1ff3b8fa05..21126e5f95 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss @@ -112,7 +112,7 @@ align-items: center; } - // Selectable option cards, shared by delivery mode and approval policy. + // Selectable approval-policy cards. // Single column: three across a 560px modal left ~165px per card, which is // not enough for a title plus a two-line description. &__options { @@ -152,7 +152,7 @@ opacity: 0.55; } - // Icon column is optional; delivery cards have no icon. + // Keep the leading policy icon aligned with the text column. > svg:first-child { margin-top: 1px; color: var(--color-text-muted); @@ -163,7 +163,6 @@ min-width: 0; flex-direction: column; gap: 2px; - // Fill the icon column when this card has no icon. grid-column: 2 / 3; } @@ -183,8 +182,7 @@ } } - // Consent gate. Warning colour is reserved for this — it is the only place - // the user is accepting a risk rather than triggering an action. + // Baseline summary and the opt-in for carrying local Git-visible changes. &__consent { display: flex; flex-direction: column; @@ -214,6 +212,30 @@ line-height: 1.4; cursor: pointer; } + + .dispatch-install-dialog__base-ref { + flex-direction: column; + gap: $size-gap-1; + cursor: text; + + input { + box-sizing: border-box; + width: 100%; + min-height: 32px; + padding: 0 $size-gap-2; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + background: var(--color-bg-primary); + color: var(--color-text-primary); + font-family: var(--font-family-mono); + + &:focus-visible { + border-color: var(--color-accent-500); + outline: 2px solid color-mix(in srgb, var(--color-accent-500) 24%, transparent); + outline-offset: 1px; + } + } + } } &__checks { diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index 21abb82144..997b815e23 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -4,19 +4,19 @@ 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(), installCliSourceStart: vi.fn(), installCliPoll: vi.fn(), installCliCancel: vi.fn(), syncModelConfig: vi.fn(), confirmWarning: vi.fn(), getConfig: vi.fn(), + getFreshConfig: vi.fn(), + resolveRevision: vi.fn(), modalOnClose: null as (() => void) | null, modalLifecycleProps: null as { closeOnOverlayClick?: boolean; @@ -27,7 +27,6 @@ const mocks = vi.hoisted(() => ({ vi.mock('./dispatchApi', () => ({ dispatchApi: { probeTarget: mocks.probeTarget, - installCliStart: mocks.installCliStart, installCliSourceStart: mocks.installCliSourceStart, installCliPoll: mocks.installCliPoll, installCliCancel: mocks.installCliCancel, @@ -45,6 +44,14 @@ vi.mock('@/infrastructure/config', () => ({ configManager: { getConfig: mocks.getConfig }, })); +vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ + configAPI: { getConfig: mocks.getFreshConfig }, +})); + +vi.mock('@/infrastructure/api/service-api/GitAPI', () => ({ + gitAPI: { resolveRevision: mocks.resolveRevision }, +})); + vi.mock('@/infrastructure/config/services/modelConfigs', () => ({ getModelDisplayName: (config: { name?: string; model_name?: string }) => `${config.name ?? ''}/${config.model_name ?? ''}`, @@ -140,6 +147,8 @@ describe('DispatchInstallDialog installation lifecycle', () => { mocks.confirmWarning.mockResolvedValue(true); mocks.installCliCancel.mockResolvedValue(undefined); mocks.getConfig.mockResolvedValue([]); + mocks.getFreshConfig.mockResolvedValue(undefined); + mocks.resolveRevision.mockResolvedValue('a'.repeat(40)); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -150,10 +159,9 @@ describe('DispatchInstallDialog installation lifecycle', () => { 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(); + it('shows the verified release as automatic and follows the worktree copy setting', async () => { + const onReady = vi.fn(); + mocks.getFreshConfig.mockResolvedValue({ copyLocalChanges: true }); await act(async () => { root.render( @@ -164,57 +172,137 @@ describe('DispatchInstallDialog installation lifecycle', () => { connectionId: 'ssh-1', displayName: 'build-host', }} - onClose={onClose} - onReady={vi.fn()} + sourceWorkspacePath="/home/me/project" + onClose={vi.fn()} + onReady={onReady} />, ); await Promise.resolve(); + await Promise.resolve(); }); - const installButton = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('dispatch.installConfirm')); - expect(installButton).toBeDefined(); + expect(container.textContent).toContain('dispatch.installAutomaticTitle'); + expect(container.textContent).toContain('1.2.3'); + expect(container.textContent).toContain('abc123'); + expect(container.textContent).not.toContain('dispatch.installConfirm'); + expect(mocks.modalLifecycleProps).toEqual({ + closeOnOverlayClick: true, + showCloseButton: true, + }); + const includeUncommitted = container.querySelector('input[type="checkbox"]'); + expect(includeUncommitted?.checked).toBe(true); await act(async () => { - installButton?.click(); + Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.approvalReject')) + ?.click(); + }); + + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.useTarget')) + ?.click(); + await Promise.resolve(); await Promise.resolve(); }); - expect(mocks.installCliStart).toHaveBeenCalledTimes(1); - expect(mocks.modalLifecycleProps).toEqual({ - closeOnOverlayClick: true, - showCloseButton: true, + + expect(mocks.getFreshConfig).toHaveBeenCalledWith('app.worktrees', { + skipRetryOnNotFound: true, }); - const snapshotButton = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('dispatch.deliverySnapshot')); - expect(snapshotButton?.disabled).toBe(true); - expect(snapshotButton?.textContent).toContain('dispatch.deliverySnapshotUnavailable'); - const cancelButton = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent === 'dispatch.cancel'); - expect(cancelButton?.disabled).toBe(false); + expect(mocks.resolveRevision).toHaveBeenCalledWith('/home/me/project', 'HEAD'); + expect(onReady).toHaveBeenCalledWith(expect.objectContaining({ + baseRef: 'HEAD', + includeUncommitted: true, + approvalPolicy: 'reject-and-report', + request: { kind: 'ssh', connectionId: 'ssh-1', workspacePath: '' }, + })); + }); + + it('does not overwrite a user choice when the worktree default resolves late', async () => { + const worktreeSettings = createDeferred<{ copyLocalChanges: boolean }>(); + mocks.getFreshConfig.mockReturnValue(worktreeSettings.promise); await act(async () => { - mocks.modalOnClose?.(); + root.render( + , + ); await Promise.resolve(); }); - expect(onClose).toHaveBeenCalledTimes(1); - expect(mocks.installCliCancel).toHaveBeenCalledTimes(1); + + const includeUncommitted = container.querySelector( + 'input[type="checkbox"]', + ); + expect(includeUncommitted?.checked).toBe(false); 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', - }); + includeUncommitted?.click(); + }); + expect(includeUncommitted?.checked).toBe(true); + + await act(async () => { + worktreeSettings.resolve({ copyLocalChanges: false }); 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(); + expect(includeUncommitted?.checked).toBe(true); + }); + + it('keeps setup open and reports an invalid base revision before creating a session', async () => { + const onReady = vi.fn(); + mocks.resolveRevision.mockRejectedValue(new Error('unknown revision')); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + const baseRefInput = container.querySelector( + '.dispatch-install-dialog__base-ref input', + ); + await act(async () => { + if (baseRefInput) { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set?.call(baseRefInput, 'missing/ref'); + baseRefInput.dispatchEvent(new Event('input', { bubbles: true })); + } + Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.approvalReject')) + ?.click(); + }); + + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.useTarget')) + ?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.resolveRevision).toHaveBeenCalledWith( + '/home/me/project', + 'missing/ref', + ); + expect(onReady).not.toHaveBeenCalled(); + expect(container.textContent).toContain('dispatch.baseRefInvalid'); + expect(container.querySelector('.dispatch-install-dialog')).not.toBeNull(); }); it('offers a source build only when the target can actually run one', async () => { @@ -273,12 +361,19 @@ describe('DispatchInstallDialog installation lifecycle', () => { url: 'https://github.com/GCWing/BitFun.git', sha256: '', }); - mocks.installCliPoll.mockResolvedValue({ cursor: 0, output: '', status: 'running' }); + mocks.installCliPoll.mockResolvedValue({ cursor: 1, output: '', status: 'failed' }); - const checkButton = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('dispatch.check')); await act(async () => { - checkButton?.click(); + root.render( + , + ); + await Promise.resolve(); await Promise.resolve(); }); @@ -288,12 +383,13 @@ describe('DispatchInstallDialog installation lifecycle', () => { await act(async () => { ready?.click(); await Promise.resolve(); + await Promise.resolve(); }); expect(mocks.confirmWarning).toHaveBeenCalled(); expect(mocks.installCliSourceStart).toHaveBeenCalledWith('ssh-1'); }); - it('names where snapshot results stay, since nothing is synced back', async () => { + it('explains the Git baseline and never offers a snapshot delivery mode', async () => { await act(async () => { root.render( { await Promise.resolve(); }); - const snapshot = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('dispatch.deliverySnapshot')); - await act(async () => { - snapshot?.click(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('dispatch.snapshotResultLocationHint'); + expect(container.textContent).toContain('dispatch.baselineSource'); + expect(container.textContent).toContain('dispatch.baselineDescription'); + expect(container.textContent).toContain('dispatch.baseRefHint'); + expect(container.textContent).toContain('dispatch.includeUncommittedHint'); + expect(container.textContent).not.toContain('dispatch.deliverySnapshot'); + expect(container.textContent).not.toContain('dispatch.snapshotResultLocationHint'); }); - it('defaults an unbound target to a source snapshot and preserves target model facts', async () => { + it('preserves protocol v3 target model facts without a delivery-mode choice', async () => { const onReady = vi.fn(); mocks.probeTarget.mockResolvedValue({ cliInstalled: true, @@ -325,7 +419,7 @@ describe('DispatchInstallDialog installation lifecycle', () => { arch: 'x86_64', installSupported: false, protocol: { - protocolVersion: 2, + protocolVersion: 3, cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', @@ -336,8 +430,9 @@ describe('DispatchInstallDialog installation lifecycle', () => { 'frontend_event_projection', 'workspace_serialization', 'dispatch_worker_cli_profile', - 'workspace_snapshot_exact', - 'workspace_snapshot_chunked', + 'workspace_git_worktree', + 'workspace_git_bundle_upload', + 'workspace_git_sync', 'approval_remote', ], modelConfigured: true, @@ -360,10 +455,6 @@ describe('DispatchInstallDialog installation lifecycle', () => { await Promise.resolve(); }); - const sourceSnapshot = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('dispatch.deliverySourceSnapshot')); - expect(sourceSnapshot?.getAttribute('aria-checked')).toBe('true'); - const remoteApproval = Array.from(container.querySelectorAll('button')) .find(button => button.textContent?.includes('dispatch.approvalRemote')); await act(async () => { @@ -375,12 +466,11 @@ describe('DispatchInstallDialog installation lifecycle', () => { await act(async () => { useTarget?.click(); + await Promise.resolve(); + await Promise.resolve(); }); expect(onReady).toHaveBeenCalledWith(expect.objectContaining({ - workspaceDelivery: { - kind: 'snapshot-source', - sourceWorkspacePath: '/home/me/project', - }, + includeUncommitted: false, approvalPolicy: 'remote', availableModels: ['model-a', 'model-b'], defaultModel: 'model-b', @@ -393,12 +483,25 @@ describe('DispatchInstallDialog installation lifecycle', () => { output: string; status: 'running'; }>(); - mocks.installCliStart.mockResolvedValue({ + mocks.probeTarget.mockResolvedValue({ + cliInstalled: false, + os: 'linux', + arch: 'x86_64', + installSupported: false, + prebuiltIncompatible: 'target uses musl libc', + sourceBuild: { + supported: true, + blockers: [], + gitRef: 'v1.2.3', + cargoVersion: '1.90.0', + }, + }); + mocks.installCliSourceStart.mockResolvedValue({ scriptPath: '/tmp/install-bitfun.sh', version: '1.2.3', - target: 'x86_64-unknown-linux-gnu', - url: 'https://example.test/bitfun', - sha256: 'abc123', + target: 'linux x86_64', + url: 'https://github.com/GCWing/BitFun.git', + sha256: '', }); mocks.installCliPoll.mockReturnValue(poll.promise); const target = { @@ -420,13 +523,13 @@ describe('DispatchInstallDialog installation lifecycle', () => { }); const installButton = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('dispatch.installConfirm')); + .find(button => button.textContent?.includes('dispatch.sourceBuildConfirm')); await act(async () => { installButton?.click(); await Promise.resolve(); await Promise.resolve(); }); - expect(mocks.installCliStart).toHaveBeenCalledTimes(1); + expect(mocks.installCliSourceStart).toHaveBeenCalledTimes(1); expect(mocks.installCliPoll).toHaveBeenCalledTimes(1); await act(async () => { @@ -474,7 +577,7 @@ describe('DispatchInstallDialog model configuration sync', () => { arch: 'x86_64', installSupported: true, protocol: { - protocolVersion: 2, + protocolVersion: 3, cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', @@ -484,6 +587,9 @@ describe('DispatchInstallDialog model configuration sync', () => { 'detached_worker', 'frontend_event_projection', 'workspace_serialization', + 'workspace_git_worktree', + 'workspace_git_bundle_upload', + 'workspace_git_sync', 'dispatch_worker_cli_profile', ], modelConfigured, @@ -520,6 +626,8 @@ describe('DispatchInstallDialog model configuration sync', () => { mocks.probeTarget.mockImplementation(async () => probeResult()); mocks.confirmWarning.mockResolvedValue(true); mocks.getConfig.mockResolvedValue([]); + mocks.getFreshConfig.mockResolvedValue(undefined); + mocks.resolveRevision.mockResolvedValue('a'.repeat(40)); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -627,7 +735,7 @@ describe('DispatchInstallDialog target model readout', () => { arch: 'x86_64', installSupported: true, protocol: { - protocolVersion: 2, + protocolVersion: 3, cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', @@ -637,6 +745,9 @@ describe('DispatchInstallDialog target model readout', () => { 'detached_worker', 'frontend_event_projection', 'workspace_serialization', + 'workspace_git_worktree', + 'workspace_git_bundle_upload', + 'workspace_git_sync', 'dispatch_worker_cli_profile', ], modelConfigured: true, @@ -666,6 +777,8 @@ describe('DispatchInstallDialog target model readout', () => { vi.clearAllMocks(); mocks.modalOnClose = null; mocks.confirmWarning.mockResolvedValue(true); + mocks.getFreshConfig.mockResolvedValue(undefined); + mocks.resolveRevision.mockResolvedValue('a'.repeat(40)); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx index e51e62b378..173a2cac4a 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, Button, - Input, Modal, confirmWarning, } from '@/component-library'; @@ -11,7 +10,6 @@ import { createLogger } from '@/shared/utils/logger'; import { Check, Loader2, - RefreshCw, ShieldAlert, ShieldCheck, ShieldQuestion, @@ -23,20 +21,21 @@ import type { DispatchSelection, DispatchSshProbe, DispatchTargetOption, - DispatchWorkspaceDeliveryRequest, } from './types'; import { BASE_DISPATCH_CAPABILITIES, DISPATCH_PROTOCOL_VERSION, - isDispatchWorkspaceReady, } from './dispatchPreflight'; import { compareDispatchModels, syncableLocalModelIds, } from './dispatchModelParity'; +import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; +import { gitAPI } from '@/infrastructure/api/service-api/GitAPI'; import { configManager } from '@/infrastructure/config'; import { getModelDisplayName } from '@/infrastructure/config/services/modelConfigs'; import type { AIModelConfig } from '@/infrastructure/config/types'; +import type { WorktreeSettings } from '@/infrastructure/api/service-api/WorktreeAPI'; import './DispatchInstallDialog.scss'; const log = createLogger('DispatchInstallDialog'); @@ -76,14 +75,13 @@ export const DispatchInstallDialog: React.FC = ({ onReady, }) => { const { t } = useI18n('common'); - const [workspacePath, setWorkspacePath] = useState(''); const [approvalPolicy, setApprovalPolicy] = useState(null); - const [deliveryKind, setDeliveryKind] = useState< - 'existing' | 'snapshot-source' | 'snapshot-exact' - >('existing'); - const [sensitiveFilesConfirmed, setSensitiveFilesConfirmed] = useState(false); + const [includeUncommitted, setIncludeUncommitted] = useState(false); + const [baseRef, setBaseRef] = useState('HEAD'); + const [baseRefError, setBaseRefError] = useState(null); + const [validatingBaseRef, setValidatingBaseRef] = useState(false); + const [worktreeSettingsLoading, setWorktreeSettingsLoading] = useState(true); const [probe, setProbe] = useState(null); - const [probedWorkspaceInput, setProbedWorkspaceInput] = useState(null); const [probing, setProbing] = useState(false); const [installing, setInstalling] = useState(false); const [syncingModel, setSyncingModel] = useState(false); @@ -93,20 +91,17 @@ export const DispatchInstallDialog: React.FC = ({ const [localModels, setLocalModels] = useState(null); const generationRef = useRef(0); const activeInstallRef = useRef(null); - const workspacePathRef = useRef(workspacePath); - workspacePathRef.current = workspacePath; + const includeUncommittedTouchedRef = useRef(false); const connectionId = target?.connectionId?.trim() ?? ''; const deviceId = target?.deviceId?.trim() ?? ''; const targetId = target?.kind === 'device' ? deviceId : connectionId; - const deliveryKindRef = useRef(deliveryKind); - deliveryKindRef.current = deliveryKind; - const runProbe = useCallback(async (pathOverride?: string) => { + const runProbe = useCallback(async () => { if (!targetId || !target || target.kind === 'local') return; - const path = deliveryKindRef.current === 'existing' - ? (pathOverride ?? workspacePathRef.current).trim() - : ''; + // The target's own directories are irrelevant now: dispatch checks out its + // own worktree there, so the probe only reports CLI and model readiness. + const path = ''; const generation = ++generationRef.current; setProbing(true); setError(null); @@ -118,12 +113,10 @@ export const DispatchInstallDialog: React.FC = ({ ); if (generation === generationRef.current) { setProbe(result); - setProbedWorkspaceInput(path); } } catch (nextError) { if (generation === generationRef.current) { setProbe(null); - setProbedWorkspaceInput(null); setError(errorMessage(nextError)); } } finally { @@ -135,25 +128,21 @@ export const DispatchInstallDialog: React.FC = ({ useEffect(() => { if (!open || !targetId) return; - const initialPath = target?.defaultWorkspace?.trim() ?? ''; - const initialDelivery = initialPath - ? 'existing' - : sourceWorkspacePath?.trim() - ? 'snapshot-source' - : 'existing'; - setWorkspacePath(initialPath); setApprovalPolicy(null); - setDeliveryKind(initialDelivery); - setSensitiveFilesConfirmed(false); + includeUncommittedTouchedRef.current = false; + setIncludeUncommitted(false); + setBaseRef('HEAD'); + setBaseRefError(null); + setValidatingBaseRef(false); + setWorktreeSettingsLoading(true); setProbe(null); - setProbedWorkspaceInput(null); setInstallStart(null); setInstallOutput(''); setInstalling(false); setSyncingModel(false); setError(null); - void runProbe(initialPath); - }, [open, runProbe, sourceWorkspacePath, target?.defaultWorkspace, targetId]); + void runProbe(); + }, [open, runProbe, targetId]); // Reload on every open: the model catalog can change in settings while this // dialog is closed, and a stale local list would report a false divergence. @@ -177,6 +166,35 @@ export const DispatchInstallDialog: React.FC = ({ }; }, [open]); + // Dispatch uses the same baseline creation path as the regular worktree + // control, so its initial copy-local-changes choice follows that setting. + useEffect(() => { + if (!open) return; + let cancelled = false; + setWorktreeSettingsLoading(true); + void configAPI.getConfig('app.worktrees', { skipRetryOnNotFound: true }) + .then(settings => { + if (!cancelled && !includeUncommittedTouchedRef.current) { + const configured = settings as Partial | undefined; + setIncludeUncommitted(configured?.copyLocalChanges === true); + } + }) + .catch(nextError => { + log.warn('Failed to read worktree settings for dispatch', { + error: nextError, + }); + if (!cancelled && !includeUncommittedTouchedRef.current) { + setIncludeUncommitted(false); + } + }) + .finally(() => { + if (!cancelled) setWorktreeSettingsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, targetId]); + const clearActiveInstall = useCallback((generation: number) => { if (activeInstallRef.current?.generation === generation) { activeInstallRef.current = null; @@ -202,11 +220,6 @@ export const DispatchInstallDialog: React.FC = ({ return invalidateInstallLifecycle; }, [invalidateInstallLifecycle, open, targetId]); - useEffect(() => { - if (!open || !targetId) return; - void runProbe(deliveryKind === 'existing' ? workspacePathRef.current : ''); - }, [deliveryKind, open, runProbe, targetId]); - const pollInstallation = useCallback(async (generation: number) => { if (!connectionId) return; let cursor = 0; @@ -254,55 +267,6 @@ export const DispatchInstallDialog: React.FC = ({ } }, [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]); - // Same lifecycle as a release install — it shares the target-side driver, // log, and poll/cancel machinery, so only the start call differs. const startSourceBuild = useCallback(async () => { @@ -379,16 +343,10 @@ export const DispatchInstallDialog: React.FC = ({ }, [invalidateInstallLifecycle, onClose]); const protocol = probe?.protocol; - const workspace = protocol?.workspace; const selectedApprovalCapability = approvalCapability(approvalPolicy); const requiredCapabilities = [ ...BASE_DISPATCH_CAPABILITIES, ...(selectedApprovalCapability ? [selectedApprovalCapability] : []), - ...(deliveryKind === 'snapshot-source' - ? ['workspace_snapshot_exact', 'workspace_snapshot_chunked'] - : deliveryKind === 'snapshot-exact' - ? ['workspace_snapshot_exact', 'workspace_snapshot_chunked'] - : []), ]; const missingCapabilities = protocol ? requiredCapabilities.filter(capability => !protocol.capabilities.includes(capability)) @@ -401,13 +359,22 @@ export const DispatchInstallDialog: React.FC = ({ !!protocol && !probe.protocolError && protocolCompatible; - const workspaceReady = deliveryKind === 'snapshot-source' - ? !!sourceWorkspacePath?.trim() - : deliveryKind === 'snapshot-exact' - ? !!sourceWorkspacePath?.trim() && sensitiveFilesConfirmed - : isDispatchWorkspaceReady(workspacePath, workspace, probedWorkspaceInput ?? undefined); + const workspaceReady = !!sourceWorkspacePath?.trim(); const modelReady = protocol?.modelConfigured === true; - const ready = cliReady && workspaceReady && modelReady && approvalPolicy !== null; + /** + * A missing CLI no longer blocks target selection: submitting installs the + * signed release automatically. Model readiness cannot be checked until that + * CLI exists, so it stays unverified here and submit reports it instead. + */ + const installPending = + !cliReady + && target?.kind === 'ssh' + && !!probe?.installSupported + && !probe?.prebuiltIncompatible; + const ready = + approvalPolicy !== null + && workspaceReady + && (cliReady ? modelReady : installPending); const targetModelCount = protocol?.availableModels?.length ?? 0; const modelParity = compareDispatchModels( @@ -425,7 +392,7 @@ export const DispatchInstallDialog: React.FC = ({ return local ? getModelDisplayName(local) : id; })(); - const confirmTarget = () => { + const confirmTarget = async () => { if ( !target || target.kind === 'local' @@ -433,22 +400,31 @@ export const DispatchInstallDialog: React.FC = ({ || !approvalPolicy || !ready ) return; - const normalizedPath = deliveryKind === 'existing' - ? workspace?.path?.trim() || workspacePath.trim() - : ''; - const workspaceDelivery: DispatchWorkspaceDeliveryRequest = - deliveryKind === 'snapshot-source' - ? { - kind: 'snapshot-source', - sourceWorkspacePath: sourceWorkspacePath!.trim(), - } - : deliveryKind === 'snapshot-exact' - ? { - kind: 'snapshot-exact', - sourceWorkspacePath: sourceWorkspacePath!.trim(), - sensitiveFilesConfirmed: true, - } - : { kind: 'existing' }; + const normalizedSourcePath = sourceWorkspacePath?.trim() || ''; + const normalizedBaseRef = baseRef.trim() || 'HEAD'; + const generation = generationRef.current; + setValidatingBaseRef(true); + setBaseRefError(null); + try { + await gitAPI.resolveRevision(normalizedSourcePath, normalizedBaseRef); + } catch (nextError) { + if (generation === generationRef.current) { + log.warn('Failed to resolve dispatch base revision', { + repositoryPath: normalizedSourcePath, + revision: normalizedBaseRef, + error: nextError, + }); + setBaseRefError(t('dispatch.baseRefInvalid', { ref: normalizedBaseRef })); + } + return; + } finally { + if (generation === generationRef.current) { + setValidatingBaseRef(false); + } + } + if (generation !== generationRef.current) return; + // The target chooses where its worktree lands, so nothing is sent here. + const normalizedPath = ''; const request = target.kind === 'device' ? { kind: 'device' as const, @@ -467,7 +443,8 @@ export const DispatchInstallDialog: React.FC = ({ workspacePath: normalizedPath, displayName: target.displayName, }, - workspaceDelivery, + includeUncommitted, + baseRef: normalizedBaseRef, approvalPolicy, availableModels: protocol?.availableModels, defaultModel: protocol?.defaultModel, @@ -502,6 +479,14 @@ export const DispatchInstallDialog: React.FC = ({ {error ? ( setError(null)} /> ) : null} + {baseRefError ? ( + setBaseRefError(null)} + /> + ) : null}
@@ -510,132 +495,43 @@ export const DispatchInstallDialog: React.FC = ({
-
- - - -
- - {deliveryKind === 'existing' ? ( -
@@ -665,33 +561,6 @@ export const DispatchInstallDialog: React.FC = ({ : t('dispatch.cliMissing')} - {deliveryKind === 'existing' && 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} - {deliveryKind === 'existing' && 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')} @@ -719,27 +588,20 @@ export const DispatchInstallDialog: React.FC = ({

- {t('dispatch.installRequired')} + {t('dispatch.installAutomaticTitle')}

- {t('dispatch.installDescription')} + {t('dispatch.installAutomaticDescription')} + {/* The digest is still shown: automatic installation removed the + prompt, not the verification it used to display. */}
{t('dispatch.version')}
{probe.release.version}
{t('dispatch.downloadUrl')}
{probe.release.url}
SHA256
{probe.release.sha256}
-
) : null} @@ -815,7 +677,10 @@ export const DispatchInstallDialog: React.FC = ({ {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 index 2f255338eb..99225e9372 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts @@ -11,7 +11,11 @@ import { requestDispatchJobRefresh, } from './DispatchJobObserver'; import { dispatchJobStore } from './dispatchJobStore'; -import type { DispatchEvent, DispatchStatusResponse } from './types'; +import { + DISPATCH_TRANSCRIPT_SCHEMA_VERSION, + type DispatchEvent, + type DispatchStatusResponse, +} from './types'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { stateMachineManager } from '@/flow_chat/state-machine'; import { @@ -31,6 +35,8 @@ const mocks = vi.hoisted(() => ({ loadTranscript: vi.fn(), saveTranscript: vi.fn(), dispatchExternal: vi.fn(), + checkPathExists: vi.fn(), + sendSystemNotification: vi.fn(), })); vi.mock('./dispatchApi', () => ({ @@ -52,6 +58,13 @@ vi.mock('@/flow_chat/services/AgenticEventListener', () => ({ }, })); +vi.mock('@/infrastructure/api/service-api/SystemAPI', () => ({ + systemAPI: { + checkPathExists: mocks.checkPathExists, + sendSystemNotification: mocks.sendSystemNotification, + }, +})); + function runningOutboundRecord() { return { jobId: 'job-1', @@ -97,7 +110,6 @@ function registerRunningJob( title: 'Dispatch test', agentType: 'agentic', approvalPolicy: 'reject-and-report', - workspaceDelivery: { kind: 'existing' }, cursor: 0, state: 'running', appliedEventIds: [], @@ -282,7 +294,7 @@ function cachedTranscript( overrides: Record = {}, ): Record { return { - schemaVersion: 1, + schemaVersion: DISPATCH_TRANSCRIPT_SCHEMA_VERSION, jobId: 'job-1', sessionId: 'session-1', cursor: 120, @@ -330,6 +342,8 @@ describe('DispatchJobObserver', () => { mocks.loadTranscript.mockReset().mockResolvedValue(null); mocks.saveTranscript.mockReset().mockResolvedValue(true); mocks.dispatchExternal.mockReset().mockReturnValue(true); + mocks.checkPathExists.mockReset().mockResolvedValue(true); + mocks.sendSystemNotification.mockReset().mockResolvedValue(undefined); }); afterEach(() => { @@ -434,6 +448,92 @@ describe('DispatchJobObserver', () => { cleanup(); }); + it('renders SSH CLI installation audits inside the dispatch transcript', async () => { + registerRunningJob(); + const started: DispatchEvent = { + type: 'audit', + timestamp: '2026-07-28T00:00:00Z', + action: 'cli-install', + details: { + stage: 'cli-install-started', + release: { version: '1.2.3', target: 'x86_64-unknown-linux-gnu' }, + }, + }; + const succeeded: DispatchEvent = { + type: 'audit', + timestamp: '2026-07-28T00:00:01Z', + action: 'cli-install', + details: { + stage: 'cli-install-succeeded', + release: { version: '1.2.3', cliPath: '/usr/local/bin/bitfun' }, + }, + }; + mocks.status.mockResolvedValue(status({ + cursor: 2, + events: [ + started, + { + type: 'audit', + timestamp: '2026-07-28T00:00:00.500Z', + action: 'unrelated-audit', + details: {}, + }, + succeeded, + ], + })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + + await vi.advanceTimersByTimeAsync(0); + + const turn = flowChatStore + .getState() + .sessions + .get('session-1') + ?.dialogTurns[0]; + expect(turn).toMatchObject({ + id: 'dispatch_pending_job-1', + userMessage: { + content: '', + metadata: { __bitfunOptimisticDispatchJobId: 'job-1' }, + }, + }); + expect(turn?.modelRounds).toHaveLength(1); + expect(turn?.modelRounds[0].items).toEqual([ + expect.objectContaining({ + id: `dispatch-audit:${dispatchEventId(started)}`, + type: 'text', + content: expect.stringContaining('1.2.3'), + }), + expect.objectContaining({ + id: `dispatch-audit:${dispatchEventId(succeeded)}`, + type: 'text', + content: expect.stringContaining('1.2.3'), + }), + ]); + expect(mocks.dispatchExternal).not.toHaveBeenCalled(); + cleanup(); + }); + + it('marks a missing baseline worktree during observer reconciliation', async () => { + registerRunningJob(); + dispatchJobStore.getState().registerJob({ + ...dispatchJobStore.getState().jobs['job-1'], + baselineWorktreePath: '/source/.bitfun/worktrees/missing-baseline', + }); + mocks.checkPathExists.mockResolvedValue(false); + mocks.status.mockResolvedValue(status()); + const cleanup = installDispatchJobObserver(createContext()); + + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.checkPathExists).toHaveBeenCalledWith( + '/source/.bitfun/worktrees/missing-baseline', + ); + expect(dispatchJobStore.getState().jobs['job-1'].baselineWorktreeMissing) + .toBe(true); + cleanup(); + }); + it('drains every terminal page before it stops polling', async () => { registerRunningJob(); mocks.status @@ -756,6 +856,230 @@ describe('DispatchJobObserver', () => { cleanup(); }); + it('does not count controller downtime in a replayed terminal turn duration', async () => { + const startedAt = Date.parse('2026-07-28T00:00:00Z'); + const completedAt = Date.parse('2026-07-28T00:00:27Z'); + const replayedAt = Date.parse('2026-07-28T00:16:44Z'); + vi.setSystemTime(replayedAt); + registerRunningJob(); + installProcessingProjection(); + const session = flowChatStore.getState().sessions.get('session-1')!; + flowChatStore.setState(state => ({ + ...state, + sessions: new Map(state.sessions).set('session-1', { + ...session, + dialogTurns: [{ + ...session.dialogTurns[0], + startTime: replayedAt, + userMessage: { + ...session.dialogTurns[0].userMessage, + timestamp: replayedAt, + }, + }], + }), + })); + const startedEvent: DispatchEvent = { + type: 'agentEvent', + timestamp: '2026-07-28T00:00:00Z', + event: { + id: 'event-started', + frontendEventName: 'agentic://dialog-turn-started', + frontendPayload: { + sessionId: 'session-1', + turnId: 'turn-1', + userInput: 'run task', + }, + }, + }; + const completedEvent: DispatchEvent = { + type: 'agentEvent', + timestamp: '2026-07-28T00:00:27Z', + event: { + id: 'event-completed', + frontendEventName: 'agentic://dialog-turn-completed', + frontendPayload: { + sessionId: 'session-1', + turnId: 'turn-1', + success: true, + }, + }, + }; + mocks.status + .mockResolvedValueOnce(status({ + state: 'succeeded', + cursor: 12, + events: [startedEvent, completedEvent], + })) + .mockResolvedValueOnce(status({ + state: 'succeeded', + cursor: 12, + events: [], + })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + + await vi.advanceTimersByTimeAsync(0); + + const settledTurn = flowChatStore + .getState() + .sessions + .get('session-1')! + .dialogTurns[0]; + expect(mocks.status).toHaveBeenCalledTimes(2); + expect(settledTurn.status).toBe('completed'); + expect(settledTurn.startTime).toBe(startedAt); + expect(settledTurn.userMessage.timestamp).toBe(startedAt); + expect(settledTurn.endTime).toBe(completedAt); + expect(settledTurn.endTime! - settledTurn.startTime).toBe(27_000); + expect(mocks.saveTranscript).toHaveBeenCalledWith( + 'job-1', + expect.objectContaining({ + schemaVersion: DISPATCH_TRANSCRIPT_SCHEMA_VERSION, + dialogTurns: [expect.objectContaining({ + status: 'completed', + startTime: startedAt, + endTime: completedAt, + })], + }), + ); + cleanup(); + }); + + it('restores a terminal metadata placeholder before honoring terminalDrained', async () => { + registerRunningJob({ cursor: 900, appliedEventIds: ['event-stale'] }); + const registered = dispatchJobStore.getState().jobs['job-1']; + dispatchJobStore.getState().registerJob({ + ...registered, + state: 'succeeded', + terminalDrained: true, + }); + mocks.listJobs.mockResolvedValue([{ + ...runningOutboundRecord(), + lastCursor: 900, + lastState: 'succeeded', + }]); + installProcessingProjection(); + const metadataSession = flowChatStore.getState().sessions.get('session-1')!; + flowChatStore.setState(state => ({ + ...state, + sessions: new Map(state.sessions).set('session-1', { + ...metadataSession, + dialogTurns: [], + isHistorical: true, + historyState: 'metadata-only', + contextRestoreState: 'pending', + config: { + ...metadataSession.config, + dispatchCursor: 900, + }, + }), + })); + mocks.loadTranscript.mockResolvedValue(cachedTranscript({ + dialogTurns: [{ + id: 'turn-cached', + sessionId: 'session-1', + userMessage: { + id: 'user-cached', + content: 'run task', + timestamp: 1, + }, + modelRounds: [{ + id: 'round-cached', + index: 0, + items: [{ + id: 'text-cached', + type: 'text', + content: 'cached body', + status: 'completed', + isStreaming: false, + timestamp: 2, + }], + isStreaming: false, + isComplete: true, + status: 'completed', + startTime: 1, + endTime: 2, + }], + status: 'completed', + startTime: 1, + endTime: 2, + }], + })); + mocks.status + .mockResolvedValueOnce(status({ + state: 'succeeded', + cursor: 140, + events: [{ + type: 'audit', + timestamp: '2026-07-29T00:00:00Z', + action: 'unrelated-audit', + details: {}, + }], + })) + .mockResolvedValueOnce(status({ + state: 'succeeded', + cursor: 140, + })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.status).toHaveBeenCalledWith('job-1', 120); + const restoredSession = flowChatStore.getState().sessions.get('session-1'); + expect(restoredSession).toMatchObject({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + dialogTurns: [{ + id: 'turn-cached', + modelRounds: [{ + items: [{ content: 'cached body' }], + }], + }], + config: { + dispatchCursor: 140, + }, + }); + expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ + cursor: 140, + terminalDrained: true, + appliedEventIds: expect.arrayContaining(['event-cached']), + }); + expect(mocks.status).toHaveBeenNthCalledWith(2, 'job-1', 140); + cleanup(); + }); + + it('does not reset a legitimately empty live observer projection on each poll', async () => { + registerRunningJob({ cursor: 25, appliedEventIds: ['event-setup'] }); + installProcessingProjection(); + const liveSession = flowChatStore.getState().sessions.get('session-1')!; + flowChatStore.setState(state => ({ + ...state, + sessions: new Map(state.sessions).set('session-1', { + ...liveSession, + dialogTurns: [], + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + config: { + ...liveSession.config, + dispatchCursor: 25, + }, + }), + })); + mocks.status.mockResolvedValue(status({ cursor: 25 })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.loadTranscript).not.toHaveBeenCalled(); + expect(mocks.status).toHaveBeenCalledWith('job-1', 25); + expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ + cursor: 25, + appliedEventIds: ['event-setup'], + }); + cleanup(); + }); + it('resumes a restarted projection from the cached transcript instead of replaying', async () => { // The renderer's own cursor survived in localStorage but its transcript did // not. The cache is what makes resuming possible at all, so it also decides @@ -796,7 +1120,9 @@ describe('DispatchJobObserver', () => { it('replays from byte zero when the cached transcript predates the current projection rules', async () => { registerRunningJob({ cursor: 900 }); - mocks.loadTranscript.mockResolvedValue(cachedTranscript({ schemaVersion: 0 })); + mocks.loadTranscript.mockResolvedValue(cachedTranscript({ + schemaVersion: DISPATCH_TRANSCRIPT_SCHEMA_VERSION - 1, + })); mocks.status.mockResolvedValue(status({ state: 'running', cursor: 0 })); const cleanup = installDispatchJobObserver(createTerminalContext()); @@ -808,6 +1134,54 @@ describe('DispatchJobObserver', () => { cleanup(); }); + it('rewinds an existing metadata placeholder to byte zero and advances after an invalid cache', async () => { + registerRunningJob({ cursor: 900, appliedEventIds: ['event-stale'] }); + installProcessingProjection(); + const metadataSession = flowChatStore.getState().sessions.get('session-1')!; + flowChatStore.setState(state => ({ + ...state, + sessions: new Map(state.sessions).set('session-1', { + ...metadataSession, + dialogTurns: [], + isHistorical: true, + historyState: 'metadata-only', + contextRestoreState: 'pending', + config: { + ...metadataSession.config, + dispatchCursor: 900, + }, + }), + })); + mocks.loadTranscript.mockResolvedValue(cachedTranscript({ + schemaVersion: DISPATCH_TRANSCRIPT_SCHEMA_VERSION - 1, + })); + mocks.status + .mockResolvedValueOnce(status({ + state: 'running', + cursor: 12, + events: [{ + type: 'audit', + timestamp: '2026-07-29T00:00:00Z', + action: 'unrelated-audit', + details: {}, + }], + })) + .mockResolvedValueOnce(status({ state: 'running', cursor: 12 })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.status).toHaveBeenNthCalledWith(1, 'job-1', 0); + expect(mocks.status).toHaveBeenNthCalledWith(2, 'job-1', 12); + expect(flowChatStore.getState().sessions.get('session-1')?.config.dispatchCursor) + .toBe(12); + expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ + cursor: 12, + appliedEventIds: expect.not.arrayContaining(['event-stale']), + }); + cleanup(); + }); + it('restores truncation facts with the transcript so an incomplete history is not shown as whole', async () => { registerRunningJob(); mocks.loadTranscript.mockResolvedValue(cachedTranscript({ @@ -848,7 +1222,7 @@ describe('DispatchJobObserver', () => { const [jobId, payload] = mocks.saveTranscript.mock.calls[0]; expect(jobId).toBe('job-1'); expect(payload).toMatchObject({ - schemaVersion: 1, + schemaVersion: DISPATCH_TRANSCRIPT_SCHEMA_VERSION, jobId: 'job-1', sessionId: 'session-1', cursor: 12, diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts index 3cf2eda20f..b1ef69ca02 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts @@ -9,6 +9,7 @@ import type { DialogTurn } from '@/flow_chat/types/flow-chat'; import { clearRuntimeStatus } from '@/flow_chat/services/flow-chat-manager/RuntimeStatusModule'; import { clearRuntimeStatusState } from '@/flow_chat/store/runtimeStatusStore'; import { stateMachineManager } from '@/flow_chat/state-machine'; +import { markOptimisticDispatchTurnMetadata } from './optimisticDispatchTurn'; import { SessionExecutionEvent, SessionExecutionState, @@ -27,11 +28,16 @@ import type { DispatchJobState, DispatchStatusResponse, } from './types'; -import { isDispatchJobTerminal } from './types'; +import { + isDispatchJobTerminal, + isNonLocalDispatchTarget, +} from './types'; const log = createLogger('DispatchJobObserver'); export const DISPATCH_JOB_POLL_INTERVAL_MS = 1800; +const BASELINE_WORKTREE_CHECK_INTERVAL_MS = 30_000; +const baselineWorktreeChecks = new Map(); type RefreshRequester = (jobId?: string) => void; @@ -179,6 +185,36 @@ function isJobStillObserved(job: DispatchObserverJob): boolean { ); } +async function checkBaselineWorktree(job: DispatchObserverJob): Promise { + const path = job.baselineWorktreePath?.trim(); + if (!path) return; + const previous = baselineWorktreeChecks.get(job.jobId); + const now = Date.now(); + if ( + previous?.path === path + && now - previous.checkedAt < BASELINE_WORKTREE_CHECK_INTERVAL_MS + ) { + return; + } + baselineWorktreeChecks.set(job.jobId, { path, checkedAt: now }); + if (baselineWorktreeChecks.size > 2048) { + baselineWorktreeChecks.clear(); + baselineWorktreeChecks.set(job.jobId, { path, checkedAt: now }); + } + try { + const exists = await systemAPI.checkPathExists(path); + dispatchJobStore.getState().setBaselineWorktreeMissing(job.jobId, !exists); + } catch (error) { + // Availability is advisory here. The sync command performs the + // authoritative check and returns a typed, user-visible failure. + log.warn('Failed to check dispatch baseline worktree', { + jobId: job.jobId, + path, + error, + }); + } +} + export function dispatchEventId(event: DispatchEvent): string { if (event.type === 'agentEvent') { const envelope = event.event as DispatchAgentEventEnvelope; @@ -193,9 +229,67 @@ async function ensureProjection( context: FlowChatContext, job: DispatchObserverJob, ): Promise { + await checkBaselineWorktree(job); const sourceWorkspacePath = job.sourceWorkspacePath?.trim() || undefined; + const bindTarget = (cursor: number, cursorReset = false) => { + context.flowChatStore.updateSessionDispatchTarget(job.sessionId, { + targetRequest: job.targetRequest, + target: job.target, + jobId: job.jobId, + approvalPolicy: job.approvalPolicy, + model: job.model, + availableModels: job.availableModels, + defaultModel: job.defaultModel, + state: job.state, + cursor, + cursorReset, + sourceWorkspacePath, + sourceWorkspaceId: job.sourceWorkspaceId, + }); + }; + const restoreCachedProjection = async (): Promise<{ + hydrated: boolean; + cursor: number; + }> => { + const cached = await loadDispatchTranscript(job); + const hydrated = + !!cached && + context.flowChatStore.hydrateDispatchTranscript( + job.sessionId, + // Cache content is disk state, not a validated projection. It is + // rendered as-is, exactly like the turns the event replay would build. + cached.dialogTurns as DialogTurn[], + ); + if (hydrated && cached) { + bindTarget(cached.cursor, true); + // The cache, not the persisted renderer state, decides where to resume. + // The two are written separately, so the renderer's own cursor can be + // ahead of the last transcript that was actually stored; resuming from + // the ahead one would silently skip events the restored turns never saw. + dispatchJobStore.getState().adoptCachedReplay(job.jobId, { + cursor: cached.cursor, + appliedEventIds: cached.appliedEventIds, + eventLogComplete: cached.eventLogComplete, + historyTruncated: cached.historyTruncated, + omittedEventCount: cached.omittedEventCount, + }); + return { hydrated: true, cursor: cached.cursor }; + } + + // An empty projection and a cursor without matching turns cannot be + // resumed safely. Rebind the frontend and renderer state to byte zero so + // the durable target history rebuilds the projection under current rules. + bindTarget(0, true); + dispatchJobStore.getState().resetReplay(job.jobId); + return { hydrated: false, cursor: 0 }; + }; const existing = context.flowChatStore.getState().sessions.get(job.sessionId); if (existing) { + const needsProjectionRecovery = + existing.isHistorical === true + || existing.historyState === 'metadata-only' + || existing.config.dispatchJobId !== job.jobId + || !isNonLocalDispatchTarget(existing.config.dispatchTarget); if (existing.config.dispatchJobId !== job.jobId) { log.info('Dispatch diagnostic: observer adopted an existing flow chat session', { jobId: job.jobId, @@ -208,19 +302,22 @@ async function ensureProjection( // Reconcile both immutable target identity and controller-side ownership. // The observer can start before FlowChat knows its workspace, so a legacy // outbound record may only gain its source path on a later poll. - context.flowChatStore.updateSessionDispatchTarget(job.sessionId, { - targetRequest: job.targetRequest, - target: job.target, - jobId: job.jobId, - approvalPolicy: job.approvalPolicy, - model: job.model, - availableModels: job.availableModels, - defaultModel: job.defaultModel, - state: job.state, - cursor: job.cursor, - sourceWorkspacePath, - sourceWorkspaceId: job.sourceWorkspaceId, - }); + bindTarget(job.cursor); + // Startup metadata can win the race and create this session before the + // observer. Such a session has no turns, so merely binding the dispatch + // target would leave the navigation row permanently empty and allow the + // stale local-history path to replace the observer projection on click. + if (needsProjectionRecovery && existing.dialogTurns?.length === 0) { + const restored = await restoreCachedProjection(); + log.info('Dispatch diagnostic: observer restored an existing empty projection', { + jobId: job.jobId, + sessionId: job.sessionId, + wasHistorical: existing.isHistorical, + historyState: existing.historyState, + restoredFromCache: restored.hydrated, + resumeCursor: restored.cursor, + }); + } return true; } @@ -231,12 +328,6 @@ async function ensureProjection( return false; } - // A cursor alone cannot rebuild a projection, so it may only be resumed - // together with the transcript it produced. Read that pairing before - // touching any store: if it is missing or unusable, this falls back to the - // original behavior of replaying the whole event log from byte zero. - const cached = await loadDispatchTranscript(job); - context.flowChatStore.addExternalSession( job.sessionId, job.title, @@ -247,48 +338,10 @@ async function ensureProjection( workspaceId: job.sourceWorkspaceId, }, ); - const bindTarget = (cursor: number) => { - context.flowChatStore.updateSessionDispatchTarget(job.sessionId, { - targetRequest: job.targetRequest, - target: job.target, - jobId: job.jobId, - approvalPolicy: job.approvalPolicy, - model: job.model, - availableModels: job.availableModels, - defaultModel: job.defaultModel, - state: job.state, - cursor, - sourceWorkspacePath, - sourceWorkspaceId: job.sourceWorkspaceId, - }); - }; // Bind the target before hydrating: restoring a transcript is only allowed // on a session already known to be an observer projection. bindTarget(0); - const hydrated = - !!cached && - context.flowChatStore.hydrateDispatchTranscript( - job.sessionId, - // Cache content is disk state, not a validated projection. It is - // rendered as-is, exactly like the turns the event replay would build. - cached.dialogTurns as DialogTurn[], - ); - if (hydrated && cached) { - bindTarget(cached.cursor); - // The cache, not the persisted renderer state, decides where to resume. - // The two are written separately, so the renderer's own cursor can be - // ahead of the last transcript that was actually stored; resuming from - // the ahead one would silently skip events the restored turns never saw. - dispatchJobStore.getState().adoptCachedReplay(job.jobId, { - cursor: cached.cursor, - appliedEventIds: cached.appliedEventIds, - eventLogComplete: cached.eventLogComplete, - historyTruncated: cached.historyTruncated, - omittedEventCount: cached.omittedEventCount, - }); - } else { - dispatchJobStore.getState().resetReplay(job.jobId); - } + const restored = await restoreCachedProjection(); log.info('Dispatch diagnostic: observer created a flow chat projection', { jobId: job.jobId, sessionId: job.sessionId, @@ -297,13 +350,226 @@ async function ensureProjection( // Which of the two restore paths ran, and from where. A projection that // reports `restoredFromCache: false` on every restart is the symptom to // chase if long histories still reload page by page. - restoredFromCache: hydrated, - resumeCursor: hydrated && cached ? cached.cursor : 0, + restoredFromCache: restored.hydrated, + resumeCursor: restored.cursor, }); return context.flowChatStore.getState().sessions.has(job.sessionId); } -function applyEvent(context: FlowChatContext, event: DispatchEvent): boolean { +function auditDetail( + details: Record, + key: string, +): string | undefined { + const release = details.release; + if (!release || typeof release !== 'object') return undefined; + const value = (release as Record)[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function cliInstallAuditLabel( + event: Extract, +): string | null { + if (event.action !== 'cli-install') return null; + const stage = typeof event.details.stage === 'string' + ? event.details.stage.trim() + : ''; + const version = auditDetail(event.details, 'version'); + const target = auditDetail(event.details, 'target'); + const output = auditDetail(event.details, 'output'); + switch (stage) { + case 'cli-install-started': + return i18nService.t('flow-chat:chatInput.dispatch.cliInstallStarted', { + version: version || i18nService.t('flow-chat:chatInput.dispatch.cliInstallUnknownVersion'), + target: target || i18nService.t('flow-chat:chatInput.dispatch.remoteTarget'), + }); + case 'cli-install-succeeded': + return i18nService.t('flow-chat:chatInput.dispatch.cliInstallSucceeded', { + version: version || i18nService.t('flow-chat:chatInput.dispatch.cliInstallUnknownVersion'), + }); + case 'cli-install-failed': + return i18nService.t('flow-chat:chatInput.dispatch.cliInstallFailed', { + details: output || stage, + }); + default: + return i18nService.t('flow-chat:chatInput.dispatch.cliInstallStage', { + stage: stage || i18nService.t('flow-chat:chatInput.dispatch.cliInstallUnknownStage'), + }); + } +} + +/** + * Setup audits precede the target's SessionCreated/DialogTurnStarted events. + * Project them into the optimistic turn so they remain visible after restart, + * and so DialogTurnStarted can later adopt the same turn without duplication. + */ +function applyCliInstallAudit( + context: FlowChatContext, + job: DispatchObserverJob, + event: Extract, + eventId: string, +): void { + const label = cliInstallAuditLabel(event); + if (!label) return; + + const session = context.flowChatStore.getState().sessions.get(job.sessionId); + if (!session) return; + const turnId = `dispatch_pending_${job.jobId}`; + let turn = session.dialogTurns.find(candidate => candidate.id === turnId) + ?? session.dialogTurns[0]; + if (!turn) { + const timestamp = Date.parse(event.timestamp) || Date.now(); + const optimisticTurn: DialogTurn = { + id: turnId, + sessionId: job.sessionId, + agentType: job.agentType, + userMessage: { + id: `user_dispatch_${job.jobId}`, + // This turn exists only to retain setup audit rows until the target's + // DialogTurnStarted event arrives. Leaving the prompt empty lets that + // event hydrate the real user-visible content during a full replay. + content: '', + timestamp, + metadata: markOptimisticDispatchTurnMetadata(undefined, job.jobId), + }, + modelRounds: [], + status: 'pending', + startTime: timestamp, + }; + context.flowChatStore.addDialogTurn(job.sessionId, optimisticTurn); + turn = optimisticTurn; + } + + const roundId = `dispatch-setup:${job.jobId}`; + const timestamp = Date.parse(event.timestamp) || Date.now(); + context.flowChatStore.updateDialogTurn(job.sessionId, turn.id, current => { + const existingRound = current.modelRounds.find(round => round.id === roundId); + const item = { + id: `dispatch-audit:${eventId}`, + type: 'text' as const, + content: label, + isStreaming: false, + isMarkdown: false, + status: 'completed' as const, + timestamp, + }; + if (existingRound) { + if (existingRound.items.some(candidate => candidate.id === item.id)) { + return current; + } + return { + ...current, + modelRounds: current.modelRounds.map(round => ( + round.id === roundId + ? { ...round, items: [...round.items, item] } + : round + )), + }; + } + return { + ...current, + modelRounds: [{ + id: roundId, + index: -1, + items: [item], + isStreaming: false, + isComplete: true, + status: 'completed', + startTime: timestamp, + endTime: timestamp, + }, ...current.modelRounds], + }; + }, { touchActivity: false }); +} + +const TERMINAL_AGENT_EVENT_NAMES = new Set([ + 'agentic://dialog-turn-completed', + 'agentic://dialog-turn-failed', + 'agentic://dialog-turn-cancelled', +]); + +const START_AGENT_EVENT_NAME = 'agentic://dialog-turn-started'; + +/** + * Frontend turn creation uses the renderer clock. During byte-zero replay that + * clock is observer startup time, not task start time, so replace it with the + * durable outer event timestamp before later terminal events derive duration. + */ +function applyStartEventTimestamp( + context: FlowChatContext, + sessionId: string, + turnId: string, + timestamp: string, +): void { + const eventTime = Date.parse(timestamp); + if (!Number.isFinite(eventTime)) return; + context.flowChatStore.updateDialogTurn(sessionId, turnId, turn => { + const startTime = Math.min(turn.startTime, eventTime); + const userMessageTime = Math.min(turn.userMessage.timestamp, eventTime); + if ( + turn.startTime === startTime + && turn.userMessage.timestamp === userMessageTime + ) { + return turn; + } + return { + ...turn, + startTime, + userMessage: { + ...turn.userMessage, + timestamp: userMessageTime, + }, + }; + }, { touchActivity: false }); +} + +/** + * A dispatch event timestamp is durable target history, unlike the renderer's + * wall clock. Record it before the terminal transcript is cached so restarting + * the controller cannot turn observer downtime into task duration. + */ +function applyTerminalEventTimestamp( + context: FlowChatContext, + sessionId: string, + turnId: string, + timestamp: string, +): void { + const eventTime = Date.parse(timestamp); + if (!Number.isFinite(eventTime)) return; + context.flowChatStore.updateDialogTurn(sessionId, turnId, turn => { + const endTime = Math.max(turn.startTime, eventTime); + return turn.endTime === endTime ? turn : { ...turn, endTime }; + }, { touchActivity: false }); +} + +function applyEvent( + context: FlowChatContext, + job: DispatchObserverJob, + event: DispatchEvent, + eventId: string, +): boolean { + if (event.type === 'audit') { + applyCliInstallAudit(context, job, event, eventId); + return true; + } + if (event.type === 'jobState') { + if (isDispatchJobTerminal(event.state)) { + const turns = context.flowChatStore + .getState() + .sessions + .get(job.sessionId) + ?.dialogTurns ?? []; + const lastTurn = turns[turns.length - 1]; + if (lastTurn) { + applyTerminalEventTimestamp( + context, + job.sessionId, + lastTurn.id, + event.timestamp, + ); + } + } + return true; + } if (event.type !== 'agentEvent') { return true; } @@ -320,6 +586,26 @@ function applyEvent(context: FlowChatContext, event: DispatchEvent): boolean { return false; } context.eventBatcher.flushNow(); + const turnId = typeof projected.payload.turnId === 'string' + ? projected.payload.turnId + : undefined; + if (turnId) { + if (projected.eventName === START_AGENT_EVENT_NAME) { + applyStartEventTimestamp( + context, + job.sessionId, + turnId, + event.timestamp, + ); + } else if (TERMINAL_AGENT_EVENT_NAMES.has(projected.eventName)) { + applyTerminalEventTimestamp( + context, + job.sessionId, + turnId, + event.timestamp, + ); + } + } return true; } @@ -464,7 +750,7 @@ async function refreshJobPage( if (dispatchJobStore.getState().hasAppliedEvent(job.jobId, eventId)) { continue; } - if (!applyEvent(context, event)) { + if (!applyEvent(context, job, event, eventId)) { return 'settled'; } // Persist each applied id immediately. If a later event in this response @@ -495,7 +781,16 @@ async function refreshJobPage( `${job.sessionId}:${lastTurnBeforeSnapshot.id}`, ) ?? false ); - const needsTerminalFallback = terminalDrained && !terminalEventHandled; + const terminalTurnSettled = + !!lastTurnBeforeSnapshot && + ( + lastTurnBeforeSnapshot.status === 'completed' || + lastTurnBeforeSnapshot.status === 'cancelled' || + lastTurnBeforeSnapshot.status === 'error' + ) && + lastTurnBeforeSnapshot.endTime !== undefined; + const needsTerminalFallback = + terminalDrained && (!terminalEventHandled || !terminalTurnSettled); const applied = context.flowChatStore.applyDispatchSnapshot(job.sessionId, { jobId: job.jobId, state: response.state, @@ -658,7 +953,18 @@ async function refreshJob( if (!await ensureProjection(context, job)) { return; } - if (projectionExisted && isDispatchJobTerminal(job.state) && job.terminalDrained) { + // Cache adoption and byte-zero fallback both reset terminalDrained. Re-read + // after projection reconciliation so a stale pre-ensure snapshot cannot + // suppress the status request that completes restoration. + const reconciledJob = dispatchJobStore.getState().jobs[requestedJobId]; + if (!reconciledJob) { + return; + } + if ( + projectionExisted + && isDispatchJobTerminal(reconciledJob.state) + && reconciledJob.terminalDrained + ) { return; } diff --git a/src/web-ui/src/features/dispatch/DispatchResultDialog.scss b/src/web-ui/src/features/dispatch/DispatchResultDialog.scss index 3b34432419..3df3975e15 100644 --- a/src/web-ui/src/features/dispatch/DispatchResultDialog.scss +++ b/src/web-ui/src/features/dispatch/DispatchResultDialog.scss @@ -1,6 +1,6 @@ @use '../../component-library/styles/tokens' as *; -// Same type scale and shell as DispatchInstallDialog, so the two dispatch +// Same type scale and shell as the dispatch target setup dialog, so the two // dialogs read as one feature. .dispatch-result-dialog { display: flex; @@ -121,6 +121,16 @@ color: var(--color-text-secondary); font-family: var(--font-family-mono); font-size: var(--font-size-xs); + + > strong { + min-width: 2ch; + color: var(--color-text-muted); + font-weight: 600; + } + + > span { + overflow-wrap: anywhere; + } } // Deletions read as removals, not as ordinary paths. @@ -140,6 +150,12 @@ } } + &__empty { + padding: $size-gap-3; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + } + &__actions { display: flex; justify-content: flex-end; diff --git a/src/web-ui/src/features/dispatch/DispatchResultDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchResultDialog.test.tsx index caeee06a8d..09b2b6efcc 100644 --- a/src/web-ui/src/features/dispatch/DispatchResultDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchResultDialog.test.tsx @@ -8,15 +8,12 @@ import { DispatchResultDialog } from './DispatchResultDialog'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; const mocks = vi.hoisted(() => ({ - pullResult: vi.fn(), - applyResult: vi.fn(), - confirmWarning: vi.fn(), + syncResult: vi.fn(), })); vi.mock('./dispatchApi', () => ({ dispatchApi: { - pullResult: mocks.pullResult, - applyResult: mocks.applyResult, + syncResult: mocks.syncResult, }, })); @@ -37,51 +34,53 @@ vi.mock('@/component-library', () => ({ ), Modal: ({ children, isOpen }: React.PropsWithChildren<{ isOpen: boolean }>) => isOpen ?
{children}
: null, - confirmWarning: mocks.confirmWarning, })); -const BUNDLE = { - bundlePath: '/root/.bitfun/dispatch/workspaces/job-1/result.tar.gz', - localBundlePath: '/home/me/.bitfun/dispatch/outbound/.results/job-1.tar.gz', - workspacePath: '/root/.bitfun/dispatch/workspaces/job-1/current', - summary: { - added: ['new.txt'], - modified: ['edit.txt'], - deleted: ['gone.txt'], - baselineSha256: { 'edit.txt': 'a'.repeat(64), 'gone.txt': 'b'.repeat(64) }, - archiveSize: 1024, - archiveSha256: 'c'.repeat(64), - }, +const SYNCED = { + changed: true, + branch: 'bitfun/dispatch/job-1', + baseCommit: '0'.repeat(40), + headCommit: '1'.repeat(40), + commitCount: 2, + changes: [ + { status: 'A', path: 'new.txt' }, + { status: 'M', path: 'src/main.ts' }, + ], + truncatedChanges: false, + baselineWorktreePath: '/home/me/.bitfun/worktrees/repo/dispatch-job-1', + syncedHeadCommit: '1'.repeat(40), }; -describe('DispatchResultDialog', () => { +describe('DispatchResultDialog Git sync', () => { let container: HTMLDivElement; let root: Root; - const buttons = () => Array.from(container.querySelectorAll('button')); const buttonWith = (text: string) => - buttons().find(button => button.textContent?.includes(text)); + Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes(text)); - const render = async (props?: Partial>) => { + async function render( + props: Partial> = {}, + ): Promise { await act(async () => { root.render( , ); await Promise.resolve(); - await Promise.resolve(); }); - }; + } beforeEach(() => { vi.clearAllMocks(); - mocks.pullResult.mockResolvedValue(BUNDLE); - mocks.confirmWarning.mockResolvedValue(true); + mocks.syncResult.mockResolvedValue(SYNCED); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -92,111 +91,64 @@ describe('DispatchResultDialog', () => { container.remove(); }); - it('lists every change before anything can be applied', async () => { - mocks.applyResult.mockResolvedValue({ - written: [], - removed: [], - conflicts: [], - aborted: false, - }); + it('shows the immutable branch and baseline before explicitly syncing', async () => { await render(); - expect(mocks.pullResult).toHaveBeenCalledWith('job-1'); - expect(container.textContent).toContain('new.txt'); - expect(container.textContent).toContain('edit.txt'); - expect(container.textContent).toContain('gone.txt'); - // Pulling alone must never write. - expect(mocks.applyResult).not.toHaveBeenCalled(); + expect(container.textContent).toContain('bitfun/dispatch/job-1'); + expect(container.textContent).toContain('/home/me/.bitfun/worktrees/repo/dispatch-job-1'); + expect(mocks.syncResult).not.toHaveBeenCalled(); await act(async () => { - buttonWith('dispatch.resultApply')?.click(); + buttonWith('dispatch.syncAction')?.click(); + await Promise.resolve(); await Promise.resolve(); }); - expect(mocks.applyResult).toHaveBeenCalledWith('job-1', '/home/me/project', false); + + expect(mocks.syncResult).toHaveBeenCalledWith('job-1'); + expect(container.textContent).toContain('dispatch.syncSucceeded'); + expect(container.textContent).toContain('new.txt'); + expect(container.textContent).toContain('src/main.ts'); + expect(container.textContent).toContain('1'.repeat(40)); }); - it('surfaces conflicts and requires an explicit confirmation to overwrite', async () => { - mocks.applyResult.mockResolvedValueOnce({ - written: [], - removed: [], - conflicts: [{ path: 'edit.txt', reason: 'locallyModified' }], - aborted: true, + it('reports a clean target branch without inventing an apply step', async () => { + mocks.syncResult.mockResolvedValue({ + ...SYNCED, + changed: false, + headCommit: SYNCED.baseCommit, + commitCount: 0, + changes: [], }); await render(); await act(async () => { - buttonWith('dispatch.resultApply')?.click(); + buttonWith('dispatch.syncAction')?.click(); + await Promise.resolve(); await Promise.resolve(); }); - expect(container.textContent).toContain('dispatch.resultConflictWarning'); - expect(container.textContent).toContain('dispatch.resultConflictModified'); - // The plain apply is replaced by an explicit overwrite action. - expect(buttonWith('dispatch.resultApply')).toBeUndefined(); + expect(container.textContent).toContain('dispatch.syncNoChanges'); + expect(container.textContent).not.toContain('dispatch.resultApply'); + }); - mocks.applyResult.mockResolvedValueOnce({ - written: ['edit.txt'], - removed: [], - conflicts: [], - aborted: false, - }); - await act(async () => { - buttonWith('dispatch.resultOverwriteConfirm')?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); + it('fails closed before transport when the managed baseline is missing', async () => { + await render({ baselineMissing: true }); - expect(mocks.confirmWarning).toHaveBeenCalled(); - expect(mocks.applyResult).toHaveBeenLastCalledWith('job-1', '/home/me/project', true); + expect(container.textContent).toContain('dispatch.syncBaselineMissing'); + expect(buttonWith('dispatch.syncAction')?.disabled).toBe(true); + expect(mocks.syncResult).not.toHaveBeenCalled(); }); - it('does not overwrite when the confirmation is declined', async () => { - mocks.applyResult.mockResolvedValueOnce({ - written: [], - removed: [], - conflicts: [{ path: 'edit.txt', reason: 'locallyModified' }], - aborted: true, - }); + it('surfaces a target sync failure in the dialog', async () => { + mocks.syncResult.mockRejectedValue(new Error('target worktree is locked')); await render(); - await act(async () => { - buttonWith('dispatch.resultApply')?.click(); - await Promise.resolve(); - }); - mocks.confirmWarning.mockResolvedValue(false); await act(async () => { - buttonWith('dispatch.resultOverwriteConfirm')?.click(); + buttonWith('dispatch.syncAction')?.click(); await Promise.resolve(); await Promise.resolve(); }); - expect(mocks.applyResult).toHaveBeenCalledTimes(1); - expect(mocks.applyResult).not.toHaveBeenCalledWith('job-1', '/home/me/project', true); - }); - - it('reports a job that changed nothing instead of offering an empty apply', async () => { - mocks.pullResult.mockResolvedValue({ - ...BUNDLE, - summary: { - added: [], - modified: [], - deleted: [], - baselineSha256: {}, - archiveSize: 64, - archiveSha256: 'd'.repeat(64), - }, - }); - await render(); - - expect(container.textContent).toContain('dispatch.resultNoChanges'); - expect(buttonWith('dispatch.resultApply')?.disabled).toBe(true); - }); - - it('shows the pull failure rather than a blank dialog', async () => { - mocks.pullResult.mockRejectedValue(new Error('target refused the result request')); - await render(); - - expect(container.textContent).toContain('target refused the result request'); - expect(buttonWith('dispatch.resultApply')?.disabled).toBe(true); + expect(container.textContent).toContain('target worktree is locked'); }); }); diff --git a/src/web-ui/src/features/dispatch/DispatchResultDialog.tsx b/src/web-ui/src/features/dispatch/DispatchResultDialog.tsx index 743c084b47..b7c0cf6b2c 100644 --- a/src/web-ui/src/features/dispatch/DispatchResultDialog.tsx +++ b/src/web-ui/src/features/dispatch/DispatchResultDialog.tsx @@ -1,23 +1,21 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Alert, Button, Modal, confirmWarning } from '@/component-library'; +import { Alert, Button, Modal } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { createLogger } from '@/shared/utils/logger'; -import { FileDiff, FilePlus, FileX, Loader2 } from 'lucide-react'; +import { GitCommitHorizontal, Loader2 } from 'lucide-react'; import { dispatchApi } from './dispatchApi'; -import type { - DispatchResultApplyOutcome, - DispatchResultBundle, -} from './types'; +import type { DispatchSyncResult } from './types'; import './DispatchResultDialog.scss'; -const log = createLogger('DispatchResultDialog'); -const DIALOG_TITLE_ID = 'dispatch-result-dialog-title'; +const log = createLogger('DispatchSyncDialog'); +const DIALOG_TITLE_ID = 'dispatch-sync-dialog-title'; interface DispatchResultDialogProps { open: boolean; jobId: string; - /** Local workspace an applied bundle would be written into. */ - workspacePath: string; + branch?: string; + baselineWorktreePath?: string; + baselineMissing?: boolean; targetLabel?: string; onClose: () => void; } @@ -27,103 +25,51 @@ function errorMessage(error: unknown): string { } /** - * Review what a finished dispatch job changed on its target, then decide - * whether any of it reaches the local workspace. - * - * The target and the local tree diverged independently after the snapshot, so - * nothing is written until the user has seen the list and said so. When a path - * moved on both sides the apply aborts rather than picking a winner. + * Commit the target worktree and fast-forward the controller's managed + * baseline worktree from a Git bundle. The user's checkout is never touched. */ export const DispatchResultDialog: React.FC = ({ open, jobId, - workspacePath, + branch, + baselineWorktreePath, + baselineMissing = false, targetLabel, onClose, }) => { const { t } = useI18n('common'); - const [bundle, setBundle] = useState(null); - const [outcome, setOutcome] = useState(null); - const [pulling, setPulling] = useState(false); - const [applying, setApplying] = useState(false); + const [result, setResult] = useState(null); + const [syncing, setSyncing] = useState(false); const [error, setError] = useState(null); - // Discards results of requests that resolve after the dialog moved on. const generationRef = useRef(0); useEffect(() => { - if (!open) { - generationRef.current += 1; - setBundle(null); - setOutcome(null); - setError(null); - setPulling(false); - setApplying(false); - } - }, [open]); - - const pull = useCallback(async () => { - if (!jobId) return; - const generation = ++generationRef.current; - setPulling(true); + generationRef.current += 1; + setResult(null); setError(null); - setOutcome(null); - try { - const result = await dispatchApi.pullResult(jobId); - if (generation !== generationRef.current) return; - setBundle(result); - } catch (nextError) { - if (generation !== generationRef.current) return; - setError(errorMessage(nextError)); - log.warn('Failed to pull dispatch result', { jobId, error: nextError }); - } finally { - if (generation === generationRef.current) setPulling(false); - } - }, [jobId]); + setSyncing(false); + }, [jobId, open]); - useEffect(() => { - if (open && jobId) void pull(); - }, [open, jobId, pull]); - - const apply = useCallback(async (overwriteConflicts: boolean) => { - if (!jobId || !workspacePath) return; + const sync = useCallback(async () => { + if (!jobId || baselineMissing) return; const generation = ++generationRef.current; - if (overwriteConflicts) { - const confirmed = await confirmWarning( - t('dispatch.resultOverwriteTitle'), - t('dispatch.resultOverwriteMessage'), - { - confirmText: t('dispatch.resultOverwriteConfirm'), - cancelText: t('dispatch.cancel'), - }, - ); - if (!confirmed || generation !== generationRef.current) return; - } - setApplying(true); + setSyncing(true); setError(null); try { - const applied = await dispatchApi.applyResult(jobId, workspacePath, overwriteConflicts); + const synced = await dispatchApi.syncResult(jobId); if (generation !== generationRef.current) return; - setOutcome(applied); + setResult(synced); } catch (nextError) { if (generation !== generationRef.current) return; setError(errorMessage(nextError)); - log.warn('Failed to apply dispatch result', { jobId, error: nextError }); + log.warn('Failed to sync dispatch result', { jobId, error: nextError }); } finally { - if (generation === generationRef.current) setApplying(false); + if (generation === generationRef.current) setSyncing(false); } - }, [jobId, t, workspacePath]); - - const summary = bundle?.summary; - const changeCount = - (summary?.added.length ?? 0) + (summary?.modified.length ?? 0) + (summary?.deleted.length ?? 0); - const busy = pulling || applying; - const applied = !!outcome && !outcome.aborted; + }, [baselineMissing, jobId]); - const groups: Array<{ key: string; icon: React.ReactNode; label: string; paths: string[] }> = [ - { key: 'added', icon: , label: t('dispatch.resultAdded'), paths: summary?.added ?? [] }, - { key: 'modified', icon: , label: t('dispatch.resultModified'), paths: summary?.modified ?? [] }, - { key: 'deleted', icon: , label: t('dispatch.resultDeleted'), paths: summary?.deleted ?? [] }, - ]; + const resolvedBranch = result?.branch || branch; + const resolvedBaselinePath = result?.baselineWorktreePath || baselineWorktreePath; return ( = ({ closeOnOverlayClick showCloseButton ariaLabelledBy={DIALOG_TITLE_ID} - testId="dispatch-result-dialog" + testId="dispatch-sync-dialog" >

- {t('dispatch.resultTitle')} + {t('dispatch.syncTitle')}

{targetLabel - ? t('dispatch.resultSubtitleWithTarget', { target: targetLabel }) - : t('dispatch.resultSubtitle')} + ? t('dispatch.syncSubtitleWithTarget', { target: targetLabel }) + : t('dispatch.syncSubtitle')}
@@ -151,108 +97,91 @@ export const DispatchResultDialog: React.FC = ({ {error ? ( setError(null)} /> ) : null} + {baselineMissing ? ( + + ) : null} - {pulling ? ( + {resolvedBranch ? ( +
+ + {t('dispatch.syncBranch')} + + {resolvedBranch} +
+ ) : null} + {resolvedBaselinePath ? ( +
+ + {t('dispatch.syncBaselineWorktree')} + + {resolvedBaselinePath} +
+ ) : null} + + {syncing ? (
- {t('dispatch.resultPulling')} + {t('dispatch.syncingResult')}
) : null} - {summary && !pulling ? ( - changeCount === 0 ? ( - - ) : ( + {result && !syncing ? ( + result.changed ? ( <> +
- {t('dispatch.resultTargetWorkspace')} + {t('dispatch.syncHeadCommit')} - {bundle?.workspacePath} + {result.headCommit}
- {groups - .filter(group => group.paths.length > 0) - .map(group => ( -
-
- {group.icon} - {group.label} - {group.paths.length} -
-
    - {group.paths.map(path => ( -
  • {path}
  • - ))} -
-
- ))} +
+
+ + {t('dispatch.syncChangedFiles')} + {result.changes.length} +
+ {result.changes.length > 0 ? ( +
    + {result.changes.map(change => ( +
  • + {change.status} + {change.path} +
  • + ))} +
+ ) : ( +
+ {t('dispatch.syncNoFileList')} +
+ )} +
+ {result.truncatedChanges ? ( + + ) : null} + ) : ( + ) ) : null} - - {outcome?.aborted ? ( - - ) : null} - {outcome?.aborted ? ( -
-
- {t('dispatch.resultConflicts')} - {outcome.conflicts.length} -
-
    - {outcome.conflicts.map(conflict => ( -
  • - {conflict.path} - - {conflict.reason === 'locallyModified' - ? t('dispatch.resultConflictModified') - : t('dispatch.resultConflictMissing')} - -
  • - ))} -
-
- ) : null} - - {applied ? ( - - ) : null}
+ - {outcome?.aborted ? ( - - ) : ( - - )}
diff --git a/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx b/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx index 72ac6bd37d..7d54671ec6 100644 --- a/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx +++ b/src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx @@ -221,7 +221,7 @@ export const DispatchTargetPicker: React.FC = ({ {option.displayName} - {option.description || option.defaultWorkspace || t('chatInput.dispatch.sshDescription')} + {option.description || t('chatInput.dispatch.sshDescription')} {selected ? : null} diff --git a/src/web-ui/src/features/dispatch/README.md b/src/web-ui/src/features/dispatch/README.md index 57f026edb0..3a4893764d 100644 --- a/src/web-ui/src/features/dispatch/README.md +++ b/src/web-ui/src/features/dispatch/README.md @@ -7,73 +7,125 @@ dispatch. 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 +2. `local` uses the current session, worktree, persistence, and dialog-turn paths unchanged. -3. A non-local session is an observer projection. The controller must not call +3. The dispatch picker follows the same Git-workspace visibility condition as + the worktree control. A non-Git workspace cannot create a detached dispatch. +4. 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 +5. 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. That UI cache is the observer transcript stored under `~/.bitfun/dispatch/outbound/.transcripts/.json`. It holds the rendered projection, never a durable session, and the controller stores it verbatim without interpreting it. -5. Status cursors advance only after every returned event has been applied. +6. Status cursors advance only after every returned event has been processed. Agent envelope ids are deduplicated before replay. Terminal jobs keep polling until an empty page confirms that the event log is fully drained. A persisted cursor is only reusable while its transcript cache is present and valid; the cursor recorded in that cache wins over any other stored cursor, because only those two were written together. Missing, corrupt, or version-mismatched cache means replay from byte zero. -6. SSH CLI installation is always a separate, explicit confirmation. The UI - displays the resolved version, URL, and SHA256 before starting it. -7. Account devices use encrypted request/response RPC and distinct +7. Every non-local job starts from a controller-owned managed baseline + worktree. The outbound record stores its worktree id and path, base commit, + branch, source workspace identity, remote URL when available, and last + synchronized head. +8. The baseline is claimed by `dispatch:` and is excluded from automatic + worktree retention while the outbound job still depends on it. Cleanup must + release the claim before deleting the outbound record. A failed release + retains the record for retry so a claim cannot be orphaned; normal worktree + retention resumes only after release. +9. Delivery is Git-only. The target maintains a shared bare repository cache at + `~/.bitfun/dispatch/repos/` and a job worktree at + `~/.bitfun/dispatch/worktrees/`. A missing base commit is supplied by + a SHA-256-bound, Git-verified bundle; no origin write permission is required. +10. The setup dialog accepts a base revision (`HEAD` by default); the + controller resolves it once when creating the managed baseline. + `includeUncommitted` copies and commits Git-visible controller changes only + inside the managed baseline. The user's checkout is never changed, and + ignored runtime inputs are not sent to the target. +11. SSH submission automatically installs or upgrades a compatible signed + prebuilt `bitfun` release when the target runner is missing or incompatible. + The resolved version, URL, and SHA-256 remain visible, integrity checks stay + mandatory, installation has a bounded deadline, and submission probes the + installed runner again before provisioning. Each `cli-install` audit + transition is durably written to the preparation journal; the started event + is persisted before remote installer mutation, and an audit write failure + stops submission. The events are projected into the pending Dispatch turn + and cached with the transcript, so the automatic action remains visible + after replay or controller restart. +12. A source build remains a separate, explicit confirmation. It uploads and + compiles the clean confirmed controller revision, and automatic prebuilt + installation never escalates to it. +13. Account devices use encrypted request/response RPC and distinct `dispatch_target_*` commands. They never attach Peer Device Mode and an - offline target never falls back to local execution. -8. Approval policy is explicit per job: `auto`, `reject-and-report`, or + offline or incompatible target never falls back to local execution. Device + dispatch does not install software through the Relay. +14. Approval policy is explicit per job: `auto`, `reject-and-report`, or `remote`. `remote` projects pending requests into the normal permission panel. The selected policy is visible in the normal session controls; submit must not add a second confirmation dialog. -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 +15. MiniApp and quick-input hosts do not expose the dispatch picker. +16. 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 +17. One-click synchronization is available from `running` through terminal + states. It commits target changes when needed, validates that both managed + worktrees remain on the named job branch, and verifies the returned Git + bundle. The first bundle covers `baseCommit..`; later bundles cover + `..`, where `knownHead` is the last successfully stored + head and must be an ancestor of the current branch head. Only the controller + baseline advances. The user's checkout is never changed; users merge or + rebase the dispatch branch with ordinary Git tools after review. +18. Synchronization has no path-overwrite or conflict-resolution mode. A + missing baseline worktree is shown directly in the UI, and a divergent + baseline or branch mismatch fails closed rather than being reset. A running + checkpoint that meets a transient Git or index lock is retryable, leaves + `knownHead` unchanged, and can be repeated after the lock clears. +19. Deleting or archiving a projection writes a local job tombstone so outbound reconciliation cannot silently reopen it. -12. The observer ignores `SubagentSessionLinked`. Child observer ownership is not +20. The observer ignores `SubagentSessionLinked`. Child observer ownership is not implemented, so creating an unmarked child projection would violate the observer-only persistence and cancellation boundary. -13. Workspace delivery is explicit. `existing` addresses a target directory; - `snapshot-source` transfers tracked and non-ignored source without ignored - build output or secrets; `snapshot-exact` transfers one verified source - snapshot, including ignored and hidden regular files but excluding `.git`. - Neither snapshot mode is live or bidirectional synchronization. -14. Cursor pulls are multi-observer safe. Truncation and omitted events are +21. Cursor reads are multi-observer safe. Truncation and omitted events are visible completeness facts and must not be rendered as a full transcript. -15. The observer continues bounded polling while the window is hidden so +22. The observer continues bounded polling while the window is hidden so remote permission and terminal system notifications can be delivered. -16. Controller-wide outbound progress never advances a renderer's own cursor; - each observer replays and commits only the events it applied. -17. Listing jobs for an explicitly selected target adopts only outbound +23. Controller-wide outbound progress never advances a renderer's own cursor; + each observer replays and commits only the events it processed. +24. Listing jobs for an explicitly selected target adopts only outbound observer routing records. It never restores the target session into the controller's backend store or acquires local runtime ownership. -18. Model configuration sync is a separate, explicit, credential-bearing +25. Model configuration sync is a separate, explicit, credential-bearing operation with its own confirmation. It merges only the `ai` model keys into the target's `app.json`, preserves every other target setting, aborts rather than overwrite an unreadable or unparseable target config, and writes owner-only via a temp-file rename. -19. Dispatch target and status are session-scoped navigation metadata. Workspace +26. Dispatch target and status are session-scoped navigation metadata. Workspace navigation must not install a dispatch target or filter its session list by dispatch target. -20. The controller projects the initial user turn before waiting for target +27. The controller projects the initial user turn before waiting for target startup. The target's `DialogTurnStarted` event adopts that pending turn in place so queued work is visible without duplicating the message. -21. Every projected outbound observer record carries its durable +28. Every projected outbound observer record carries its durable controller-side source workspace identity. Legacy or adopted records without that identity remain hidden; the renderer must never guess ownership from whichever workspace initializes after restart. After submit acknowledgement, the controller index is authoritative and stale renderer cache without a matching record is pruned. -22. CLI compatibility is capability-based, not semver-only. A target must - advertise safe CLI-profile selection for detached workers; development - source updates use the clean controller commit only after the existing - explicit source-build confirmation. +29. CLI compatibility is protocol-v3 and capability-based, not semver-only. A + target must advertise Git worktree delivery, bundle upload and + synchronization, workspace serialization, and safe CLI-profile selection + for detached workers. +30. Before submit acknowledgement, controller setup is recoverable from + `~/.bitfun/dispatch/outbound/.preparations/.json`. The journal is + retained through the validated target acknowledgement, and preparation, + retry, and recovery for one job share a per-job run lock. +31. A preparation records the stable project path that owns the worktree + registry before creating `dispatch:`. Recovery releases that claim + only after the preparation lease expires and no matching outbound record + owns the baseline. A matching owner or an owner-read error conservatively + retains the claim and journal for retry. +32. Dispatch target options use SSH connection identity and display metadata + only. A saved SSH `defaultWorkspace` is not offered as the job's execution + directory; the target always provisions the job's managed Git worktree. diff --git a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts index c9f98dd015..6dbb4344f7 100644 --- a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts +++ b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts @@ -3,13 +3,14 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { BASE_DISPATCH_CAPABILITIES, - isDispatchWorkspaceReady, + DISPATCH_PROTOCOL_VERSION, } from './dispatchPreflight'; const OUTBOUND_DISPATCH_COMMANDS = [ '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', @@ -19,6 +20,7 @@ const OUTBOUND_DISPATCH_COMMANDS = [ 'dispatch_list_jobs', 'dispatch_answer', 'dispatch_append', + 'dispatch_sync_result', 'dispatch_load_transcript', 'dispatch_save_transcript', ] as const; @@ -56,21 +58,29 @@ describe('dispatch controller-only routing contract', () => { }); describe('dispatch preflight contract', () => { - it('requires the workspace serialization capability enforced by submit', () => { - expect(BASE_DISPATCH_CAPABILITIES).toContain('workspace_serialization'); + it('fails closed on protocol v3 Git worktree delivery', () => { + expect(DISPATCH_PROTOCOL_VERSION).toBe(3); + expect(BASE_DISPATCH_CAPABILITIES).toEqual(expect.arrayContaining([ + 'workspace_serialization', + 'workspace_git_worktree', + 'workspace_git_bundle_upload', + 'workspace_git_sync', + ])); + expect(BASE_DISPATCH_CAPABILITIES.join(' ')).not.toContain('workspace_snapshot'); + expect(BASE_DISPATCH_CAPABILITIES).not.toContain('workspace_result_bundle'); }); - 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); - expect(isDispatchWorkspaceReady('~/repo', probe, '~/repo')).toBe(true); + it('uses one Git sync command and exposes no snapshot pull/apply fallback', () => { + const api = read('./dispatchApi.ts'); + const types = read('./types.ts'); + const picker = read('./DispatchTargetPicker.tsx'); + expect(api).toContain("'dispatch_sync_result'"); + expect(api).not.toContain("'dispatch_pull_result'"); + expect(api).not.toContain("'dispatch_apply_result'"); + expect(types).not.toContain("'snapshot-source'"); + expect(types).not.toContain("'snapshot-exact'"); + expect(types).not.toContain('defaultWorkspace?:'); + expect(picker).not.toContain('option.defaultWorkspace'); }); }); diff --git a/src/web-ui/src/features/dispatch/dispatchApi.ts b/src/web-ui/src/features/dispatch/dispatchApi.ts index d7a90c8e3b..3ad6bcca16 100644 --- a/src/web-ui/src/features/dispatch/dispatchApi.ts +++ b/src/web-ui/src/features/dispatch/dispatchApi.ts @@ -6,15 +6,13 @@ import type { DispatchInstallPoll, DispatchInstallStart, DispatchJobListEntry, - DispatchResultApplyOutcome, - DispatchResultBundle, DispatchSshProbe, DispatchStatusResponse, DispatchSubmitResponse, + DispatchSyncResult, DispatchTargetOption, DispatchTargetRequest, DispatchTranscriptCache, - DispatchWorkspaceDeliveryRequest, OutboundDispatchRecord, } from './types'; @@ -60,31 +58,15 @@ export const dispatchApi = { }, /** - * Download what a finished snapshot job changed on its target. + * Bring a job's work back into this controller's baseline worktree. * - * Fetch and report only: the bundle lands in the controller's staging area - * and nothing reaches the local workspace until the user reviews the diff - * and explicitly applies it. + * One call, both halves: the target commits and bundles its branch, then the + * controller fast-forwards its baseline onto it. The user's own checkout is + * never touched — the baseline worktree is a separate directory. */ - async pullResult(jobId: string): Promise { - return api.invoke('dispatch_pull_result', { - request: { jobId }, - }); - }, - - /** - * Apply a pulled bundle to a local workspace. - * - * Aborts without writing when a path changed on both sides, unless - * `overwriteConflicts` says to take the target's version. - */ - async applyResult( - jobId: string, - workspacePath: string, - overwriteConflicts: boolean, - ): Promise { - return api.invoke('dispatch_apply_result', { - request: { jobId, workspacePath, overwriteConflicts }, + async syncResult(jobId: string, message?: string): Promise { + return api.invoke('dispatch_sync_result', { + request: { jobId, message }, }); }, @@ -96,7 +78,8 @@ export const dispatchApi = { async submit(request: { target: DispatchTargetRequest; - workspaceDelivery: DispatchWorkspaceDeliveryRequest; + baseRef?: string; + includeUncommitted: boolean; jobId: string; sessionId: string; agentType: string; diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts index 358a067464..f8fe240bac 100644 --- a/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts @@ -24,7 +24,6 @@ function registerJob(state: 'running' | 'succeeded' = 'running'): void { title: 'Dispatch test', agentType: 'agentic', approvalPolicy: 'reject-and-report', - workspaceDelivery: { kind: 'existing' }, cursor: 10, state, terminalDrained: state === 'succeeded', @@ -141,6 +140,12 @@ describe('dispatchJobStore', () => { model: 'configured-model', sourceWorkspacePath: '/controller/repo', sourceWorkspaceId: 'workspace-1', + baselineWorktreeId: 'worktree-1', + baselineWorktreePath: '/controller/.bitfun/worktrees/baseline', + baseCommit: 'abc123', + branch: 'bitfun/dispatch/job-rest', + remoteUrl: 'git@example.test:team/repo.git', + syncedHeadCommit: 'def456', lastCursor: 900, lastState: 'running', createdAt: '2026-07-28T00:00:00Z', @@ -154,10 +159,55 @@ describe('dispatchJobStore', () => { model: 'configured-model', sourceWorkspacePath: '/controller/repo', sourceWorkspaceId: 'workspace-1', + branch: 'bitfun/dispatch/job-rest', + baselineWorktreePath: '/controller/.bitfun/worktrees/baseline', + syncedHeadCommit: 'def456', cursor: 0, }); }); + it('hydrates Git sync metadata into an existing pre-ack job', () => { + registerJob(); + + dispatchJobStore.getState().mergeOutboundRecords([{ + jobId: 'job-1', + sessionId: 'session-1', + target: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target/repo', + displayName: 'build-host', + }, + sourceWorkspacePath: '/source', + baselineWorktreePath: '/source/.bitfun/worktrees/baseline', + branch: 'bitfun/dispatch/job-1', + syncedHeadCommit: 'def456', + workspacePath: '/target/repo', + promptPreview: 'Dispatch test', + lastCursor: 0, + lastState: 'running', + createdAt: '2026-07-28T00:00:00Z', + updatedAt: '2026-07-28T00:00:01Z', + }]); + + expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ + branch: 'bitfun/dispatch/job-1', + baselineWorktreePath: '/source/.bitfun/worktrees/baseline', + syncedHeadCommit: 'def456', + }); + }); + + it('marks a missing baseline worktree without changing job execution state', () => { + registerJob(); + + dispatchJobStore.getState().setBaselineWorktreeMissing('job-1', true); + + expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ + state: 'running', + baselineWorktreeMissing: true, + }); + }); + it('drops a legacy outbound job instead of guessing its source workspace', () => { const record = { jobId: 'job-restored', @@ -190,7 +240,6 @@ describe('dispatchJobStore', () => { title: 'Prompt preview', agentType: 'agentic', approvalPolicy: 'reject-and-report', - workspaceDelivery: { kind: 'existing' }, cursor: 0, state: 'running', appliedEventIds: [], @@ -209,6 +258,35 @@ describe('dispatchJobStore', () => { ).toBeUndefined(); }); + it('uses the stable baseline project when a linked source checkout is unavailable', () => { + const record = { + jobId: 'job-stable-project', + sessionId: 'session-stable-project', + target: { + kind: 'ssh' as const, + connectionId: 'ssh-1', + workspacePath: '/target/repo', + displayName: 'build-host', + }, + baselineProjectWorkspacePath: '/controller/main-project', + baselineWorktreeId: 'worktree-1', + baselineWorktreePath: '/controller/baselines/job-stable-project', + branch: 'bitfun/dispatch/job-stable-project', + workspacePath: '/target/repo', + promptPreview: 'Prompt preview', + lastCursor: 0, + lastState: 'running' as const, + createdAt: '2026-07-28T00:00:00Z', + updatedAt: '2026-07-28T00:00:01Z', + }; + + dispatchJobStore.getState().mergeOutboundRecords([record]); + + expect( + dispatchJobStore.getState().jobs['job-stable-project']?.sourceWorkspacePath, + ).toBe('/controller/main-project'); + }); + it('drops acknowledged renderer cache missing from the controller index', () => { registerJob(); dispatchJobStore.getState().mergeOutboundRecords([]); diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.ts index 4bcfcca310..c5e7e02c8b 100644 --- a/src/web-ui/src/features/dispatch/dispatchJobStore.ts +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.ts @@ -9,7 +9,6 @@ import type { DispatchReachability, DispatchTarget, DispatchTargetRequest, - DispatchWorkspaceDeliveryRequest, OutboundDispatchRecord, } from './types'; import { isDispatchJobTerminal } from './types'; @@ -136,7 +135,11 @@ export interface DispatchObserverJob { title: string; agentType: string; approvalPolicy: DispatchApprovalPolicy; - workspaceDelivery: DispatchWorkspaceDeliveryRequest; + /** Baseline branch on the controller, once the backend has resolved one. */ + branch?: string; + baselineWorktreePath?: string; + baselineWorktreeMissing?: boolean; + syncedHeadCommit?: string; model?: string; availableModels?: string[]; defaultModel?: string; @@ -195,6 +198,7 @@ interface DispatchJobStoreState { reachability: DispatchReachability, lastTransportError?: string, ) => void; + setBaselineWorktreeMissing: (jobId: string, missing: boolean) => void; resetReplay: (jobId: string) => void; adoptCachedReplay: ( jobId: string, @@ -354,7 +358,10 @@ export const useDispatchJobStore = create()( } continue; } - const sourceWorkspacePath = record.sourceWorkspacePath?.trim() || undefined; + const sourceWorkspacePath = + record.sourceWorkspacePath?.trim() + || record.baselineProjectWorkspacePath?.trim() + || undefined; if (!sourceWorkspacePath) { // A legacy/adopted record without controller-side ownership // cannot safely be projected into any workspace. In particular, @@ -369,6 +376,8 @@ export const useDispatchJobStore = create()( if (existing) { const nextState = nextJobState(existing.state, record.lastState); const progressed = nextState !== existing.state; + const nextBaselinePath = + record.baselineWorktreePath || existing.baselineWorktreePath; jobs[record.jobId] = { ...existing, target: record.target, @@ -382,6 +391,14 @@ export const useDispatchJobStore = create()( agentType: record.agentType || existing.agentType, approvalPolicy: record.approvalPolicy || existing.approvalPolicy, model: record.model || existing.model, + branch: record.branch || existing.branch, + baselineWorktreePath: nextBaselinePath, + baselineWorktreeMissing: + nextBaselinePath === existing.baselineWorktreePath + ? existing.baselineWorktreeMissing + : undefined, + syncedHeadCommit: + record.syncedHeadCommit || existing.syncedHeadCommit, // `lastCursor` is controller-wide diagnostic progress. A // renderer cursor is per observer and must never jump because // another observer polled the same target job. @@ -408,7 +425,9 @@ export const useDispatchJobStore = create()( title: record.title || record.promptPreview || record.sessionId.slice(0, 8), agentType: record.agentType || 'agentic', approvalPolicy: record.approvalPolicy || 'reject-and-report', - workspaceDelivery: { kind: 'existing' }, + branch: record.branch, + baselineWorktreePath: record.baselineWorktreePath, + syncedHeadCommit: record.syncedHeadCommit, model: record.model, // A newly reconstructed projection must replay its own // transcript instead of inheriting another observer's cursor. @@ -506,6 +525,25 @@ export const useDispatchJobStore = create()( }); }, + setBaselineWorktreeMissing: (jobId, missing) => { + set(state => { + const current = state.jobs[jobId]; + if (!current || current.baselineWorktreeMissing === missing) { + return state; + } + return { + jobs: { + ...state.jobs, + [jobId]: { + ...current, + baselineWorktreeMissing: missing, + updatedAt: Date.now(), + }, + }, + }; + }); + }, + resetReplay: (jobId) => { set(state => { const current = state.jobs[jobId]; diff --git a/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts b/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts index 930baa110a..8d5db4d1d3 100644 --- a/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts +++ b/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts @@ -1,15 +1,22 @@ import { describe, expect, it } from 'vitest'; -import { isDispatchWorkspaceReady } from './dispatchPreflight'; +import { + BASE_DISPATCH_CAPABILITIES, + DISPATCH_PROTOCOL_VERSION, +} from './dispatchPreflight'; describe('dispatch preflight', () => { - it('accepts only the exact probed target workspace', () => { - const workspace = { - path: '/srv/app', - exists: true, - isDirectory: true, - isGitRepository: true, - }; - expect(isDispatchWorkspaceReady('/srv/app', workspace)).toBe(true); - expect(isDispatchWorkspaceReady('/srv/other', workspace)).toBe(false); + it('requires protocol v3 Git worktree delivery without a snapshot fallback', () => { + expect(DISPATCH_PROTOCOL_VERSION).toBe(3); + expect(BASE_DISPATCH_CAPABILITIES).toEqual(expect.arrayContaining([ + 'workspace_git_worktree', + 'workspace_git_bundle_upload', + 'workspace_git_sync', + ])); + expect(BASE_DISPATCH_CAPABILITIES).not.toEqual(expect.arrayContaining([ + 'workspace_snapshot_exact', + 'workspace_snapshot_chunked', + 'workspace_snapshot_cache', + 'workspace_result_bundle', + ])); }); }); diff --git a/src/web-ui/src/features/dispatch/dispatchPreflight.ts b/src/web-ui/src/features/dispatch/dispatchPreflight.ts index 1f71c579c9..af0f58169a 100644 --- a/src/web-ui/src/features/dispatch/dispatchPreflight.ts +++ b/src/web-ui/src/features/dispatch/dispatchPreflight.ts @@ -1,29 +1,20 @@ -import type { - DispatchWorkspaceProbe, -} from './types'; - -export const DISPATCH_PROTOCOL_VERSION = 2; +export const DISPATCH_PROTOCOL_VERSION = 3; +/** + * Capabilities every dispatch target must advertise. + * + * The Git-worktree entries are not feature-detected extras: there is no + * snapshot delivery left to fall back to, so a target missing any of them + * cannot run a dispatch at all. + */ export const BASE_DISPATCH_CAPABILITIES = [ 'persistent_jobs', 'cursor_events', 'detached_worker', 'frontend_event_projection', 'workspace_serialization', + 'workspace_git_worktree', + 'workspace_git_bundle_upload', + 'workspace_git_sync', 'dispatch_worker_cli_profile', ] as const; - -export function isDispatchWorkspaceReady( - workspacePath: string, - workspace: DispatchWorkspaceProbe | undefined, - probedWorkspacePath: string | undefined = workspace?.path, -): boolean { - const normalizedPath = workspacePath.trim(); - return ( - normalizedPath.length > 0 && - normalizedPath === probedWorkspacePath?.trim() && - workspace?.exists === true && - workspace?.isDirectory === true && - !!workspace?.path.trim() - ); -} diff --git a/src/web-ui/src/features/dispatch/types.ts b/src/web-ui/src/features/dispatch/types.ts index 5669e96817..c585a07956 100644 --- a/src/web-ui/src/features/dispatch/types.ts +++ b/src/web-ui/src/features/dispatch/types.ts @@ -19,17 +19,23 @@ export type DispatchTarget = }; export type DispatchApprovalPolicy = 'auto' | 'reject-and-report' | 'remote'; -export type DispatchWorkspaceDeliveryRequest = - | { kind: 'existing' } - | { - kind: 'snapshot-source'; - sourceWorkspacePath: string; - } - | { - kind: 'snapshot-exact'; - sourceWorkspacePath: string; - sensitiveFilesConfirmed: true; - }; + +/** + * How a dispatch reaches its target: a Git worktree of the controller's own + * repository, checked out on the target at the same commit. + * + * Recorded on the session so the sync button knows where to fetch the target's + * branch back into. It is resolved by the backend at submit time, not chosen in + * the UI — the UI only decides `includeUncommitted`. + */ +export interface DispatchWorkspaceDelivery { + sourceWorkspacePath: string; + baselineWorktreeId: string; + baseCommit: string; + branch: string; + remoteUrl?: string; + includeUncommitted: boolean; +} export type DispatchReachability = 'unknown' | 'reachable' | 'unreachable'; export type DispatchJobState = | 'submitting' @@ -46,7 +52,6 @@ export interface DispatchTargetOption { deviceId?: string; displayName: string; description?: string; - defaultWorkspace?: string; online?: boolean; } @@ -97,37 +102,30 @@ export interface DispatchSshProbe { sourceBuild?: DispatchSourceBuild; } -/** What a finished snapshot job changed, plus where the bundle was staged. */ -export interface DispatchResultBundle { - bundlePath: string; - localBundlePath: string; - workspacePath: string; - summary: DispatchResultSummary; -} - -export interface DispatchResultSummary { - added: string[]; - modified: string[]; - deleted: string[]; - /** Snapshot digest per changed path, used to detect local divergence. */ - baselineSha256: Record; - archiveSize: number; - archiveSha256: string; -} - -export type DispatchResultConflictReason = 'locallyModified' | 'locallyMissing'; - -export interface DispatchResultConflict { +export interface DispatchSyncedChange { + /** Git name-status letter: `A`, `M`, `D`, and so on. */ + status: string; path: string; - reason: DispatchResultConflictReason; } -export interface DispatchResultApplyOutcome { - written: string[]; - removed: string[]; - conflicts: DispatchResultConflict[]; - /** True when nothing was touched because conflicts were found. */ - aborted: boolean; +/** + * Outcome of one sync-back. + * + * `changed: false` means the target's worktree still matches `baseCommit` — no + * bundle was built and nothing was fetched. + */ +export interface DispatchSyncResult { + changed: boolean; + branch: string; + baseCommit: string; + headCommit: string; + commitCount: number; + changes: DispatchSyncedChange[]; + /** True when the change list was capped; the fetched history is complete. */ + truncatedChanges: boolean; + /** Controller worktree the branch was fast-forwarded into. */ + baselineWorktreePath?: string; + syncedHeadCommit?: string; } export interface DispatchSourceBuild { @@ -230,6 +228,16 @@ export interface OutboundDispatchRecord { /** Controller workspace that owns the observer session. */ sourceWorkspacePath?: string; sourceWorkspaceId?: string; + /** Managed worktree this job branched from; absent on pre-Git records. */ + baselineWorktreeId?: string; + baselineWorktreePath?: string; + /** Stable main checkout that owns the managed-worktree registry. */ + baselineProjectWorkspacePath?: string; + baseCommit?: string; + branch?: string; + remoteUrl?: string; + /** Branch tip the last successful sync fetched. */ + syncedHeadCommit?: string; workspacePath: string; promptPreview: string; title?: string; @@ -249,7 +257,7 @@ export interface OutboundDispatchRecord { * the only thing standing between a projection change and a transcript rendered * by rules that no longer exist. */ -export const DISPATCH_TRANSCRIPT_SCHEMA_VERSION = 1; +export const DISPATCH_TRANSCRIPT_SCHEMA_VERSION = 4; /** * The controller's UI cache for one observer projection. @@ -273,7 +281,10 @@ export interface DispatchTranscriptCache { export interface DispatchSelection { request: Exclude; target: Exclude; - workspaceDelivery: DispatchWorkspaceDeliveryRequest; + /** Fold the baseline worktree's uncommitted changes into the base commit. */ + includeUncommitted: boolean; + /** Git revision resolved when creating the controller baseline worktree. */ + baseRef: string; approvalPolicy: DispatchApprovalPolicy; model?: string; availableModels?: string[]; diff --git a/src/web-ui/src/features/dispatch/useDispatchTargets.ts b/src/web-ui/src/features/dispatch/useDispatchTargets.ts index 5fbf1f1d74..a40071638a 100644 --- a/src/web-ui/src/features/dispatch/useDispatchTargets.ts +++ b/src/web-ui/src/features/dispatch/useDispatchTargets.ts @@ -13,6 +13,7 @@ export function useDispatchTargets(enabled = true): { } { const [targets, setTargets] = useState([]); const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); const [error, setError] = useState(null); const refresh = useCallback(async () => { @@ -31,6 +32,7 @@ export function useDispatchTargets(enabled = true): { setTargets([{ kind: 'local', displayName: 'Local' }]); } finally { setLoading(false); + setLoaded(true); } }, [enabled]); @@ -38,5 +40,8 @@ export function useDispatchTargets(enabled = true): { void refresh(); }, [refresh]); - return { targets, loading, error, refresh }; + // Opening the picker enables this hook one render before the effect starts + // the request. Treat that first render as loading so users never see a + // misleading empty-target message flash before saved SSH targets arrive. + return { targets, loading: loading || (enabled && !loaded), error, refresh }; } diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx index 6b53ba91e0..34095991b0 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx @@ -131,7 +131,7 @@ vi.mock('@/component-library', () => ({ Alert: () => null, })); -describe('SSHConnectionDialog advanced settings', () => { +describe('SSHConnectionDialog', () => { let container: HTMLDivElement; let root: Root; @@ -158,9 +158,9 @@ describe('SSHConnectionDialog advanced settings', () => { container.remove(); }); - async function renderDialog(): Promise { + async function renderDialog(onClose = vi.fn()): Promise { await act(async () => { - root.render(); + root.render(); }); await act(async () => { await Promise.resolve(); @@ -256,4 +256,48 @@ describe('SSHConnectionDialog advanced settings', () => { container.querySelector('input[aria-label="ssh.remote.certificatePath"]')?.value ).toBe('/keys/dev-cert.pub'); }); + + it('notifies the controlling surface after a successful connection', async () => { + const onClose = vi.fn(); + remoteContextMock.connect.mockResolvedValue(undefined); + await renderDialog(onClose); + + const setValue = (label: string, value: string) => { + const input = container.querySelector(`input[aria-label="${label}"]`); + expect(input).not.toBeNull(); + act(() => { + if (input) { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set; + setter?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + } + }); + }; + + setValue('ssh.remote.host', 'example.test'); + setValue('ssh.remote.username', 'dev'); + setValue('ssh.remote.password', 'secret'); + + const connectButton = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent?.includes('ssh.remote.connect')); + expect(connectButton).not.toBeUndefined(); + await act(async () => { + connectButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(remoteContextMock.connect).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + host: 'example.test', + username: 'dev', + }), + { browseAfterConnect: true }, + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx index 485afea8f6..4f8a0ec07d 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx @@ -383,7 +383,7 @@ export const SSHConnectionDialog: React.FC = ({ setLocalError(null); try { await connect(config.id, config, { browseAfterConnect: true }); - // Don't call onClose() here - connect() handles closing the dialog via context + onClose(); } catch (e) { setLocalError(e instanceof Error ? e.message : 'Connection failed'); } finally { @@ -449,6 +449,7 @@ export const SSHConnectionDialog: React.FC = ({ }, { browseAfterConnect: true } ); + onClose(); } catch { setCredentialsPrompt(conn); } finally { @@ -484,6 +485,7 @@ export const SSHConnectionDialog: React.FC = ({ }, { browseAfterConnect: true } ); + onClose(); } catch { setCredentialsPrompt(conn); } finally { @@ -553,6 +555,7 @@ export const SSHConnectionDialog: React.FC = ({ }; await connect(conn.id, full, { browseAfterConnect: true }); setCredentialsPrompt(null); + onClose(); } catch (e) { setLocalError(e instanceof Error ? e.message : 'Connection failed'); } finally { diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index ec53cbaeef..a810f03332 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -504,6 +504,10 @@ export const ChatInput: React.FC = ({ const effectiveTargetSession = effectiveTargetSessionId ? flowChatState.sessions.get(effectiveTargetSessionId) : undefined; + const dispatchObserverJob = dispatchJobStore(state => { + const jobId = effectiveTargetSession?.config.dispatchJobId; + return jobId ? state.jobs[jobId] : undefined; + }); const isDispatchInputSession = isNonLocalDispatchTarget( effectiveTargetSession?.config.dispatchTarget, ); @@ -2052,8 +2056,18 @@ export const ChatInput: React.FC = ({ const worktreeControl = useMemo(() => { if (!effectiveTargetSessionId || !effectiveTargetSession) return undefined; if (remoteWorkspaceSession) return undefined; - if (usesDispatchTransport) return undefined; if (isSubagentInputTarget || isAcpTargetSession) return undefined; + // A dispatch always executes against a managed worktree baseline of this + // repository, so the chip reports that state instead of disappearing. It is + // never togglable: the baseline is chosen with the target, not after. + if (usesDispatchTransport) { + return { + enabled: true, + locked: true, + lockedReason: 'dispatch' as const, + onChange: () => {}, + }; + } const locked = isSessionWorktreeBindingLocked( effectiveTargetSession, @@ -2100,7 +2114,8 @@ export const ChatInput: React.FC = ({ dispatchTargetRequest: selection.request, dispatchTarget: selection.target, dispatchApprovalPolicy: selection.approvalPolicy, - dispatchWorkspaceDelivery: selection.workspaceDelivery, + dispatchIncludeUncommitted: selection.includeUncommitted, + dispatchBaseRef: selection.baseRef, // Undefined is intentional: the target's probed default model wins // unless a future preflight selector records an explicit choice. dispatchModel: selection.model, @@ -2127,18 +2142,18 @@ export const ChatInput: React.FC = ({ } const target: DispatchTarget = effectiveTargetSession?.config.dispatchTarget ?? { kind: 'local' }; - // Results only exist for a snapshot-delivered job that has finished: an - // "existing directory" job never took a snapshot to diff against, and a - // running one has no terminal tree yet. + // Syncing is available as soon as the target has a worktree to commit — + // that is, from the moment the job starts running. Waiting for a terminal + // state would block the common "let me see what it has so far" case. const jobId = effectiveTargetSession?.config.dispatchJobId; const jobState = effectiveTargetSession?.config.dispatchJobState; - const completedSnapshotJobId = - ( - effectiveTargetSession?.config.dispatchWorkspaceDelivery?.kind === 'snapshot-source' - || effectiveTargetSession?.config.dispatchWorkspaceDelivery?.kind === 'snapshot-exact' - ) && - (jobState === 'succeeded' || jobState === 'failed') && - jobId + const syncableJobId = + isNonLocalDispatchTarget(target) + && jobId + && (jobState === 'running' + || jobState === 'succeeded' + || jobState === 'failed' + || jobState === 'cancelled') ? jobId : undefined; return { @@ -2149,15 +2164,20 @@ export const ChatInput: React.FC = ({ (effectiveTargetSession?.dialogTurns.length ?? 0) > 0 || !!derivedState?.isProcessing, onSelectTarget: handleSelectDispatchTarget, - completedSnapshotJobId, + syncableJobId, + branch: dispatchObserverJob?.branch, + baselineWorktreePath: dispatchObserverJob?.baselineWorktreePath, + baselineMissing: dispatchObserverJob?.baselineWorktreeMissing, }; }, [ derivedState?.isProcessing, effectiveTargetSession?.config.dispatchJobId, effectiveTargetSession?.config.dispatchJobState, effectiveTargetSession?.config.dispatchTarget, - effectiveTargetSession?.config.dispatchWorkspaceDelivery?.kind, effectiveTargetSession?.dialogTurns.length, + dispatchObserverJob?.baselineWorktreeMissing, + dispatchObserverJob?.baselineWorktreePath, + dispatchObserverJob?.branch, handleSelectDispatchTarget, isAcpInputSession, isBtwSession, diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss index 1544a1acf8..6ab0934f39 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss @@ -73,8 +73,8 @@ flex: none; } - // Appears only when a finished snapshot job has results waiting, so it is - // given accent treatment rather than the muted default of the other controls. + // One-click branch sync is a primary recovery/review path for remote work, + // so it gets accent treatment rather than the muted default of other controls. &__dispatch-result { box-sizing: border-box; display: inline-flex; diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx index cf4169af83..1bcdda3f77 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx @@ -38,6 +38,12 @@ vi.mock('@/tools/git/hooks/useGitState', () => ({ useGitState: mocks.useGitState, })); +// The real picker pulls in account state, SSH dialogs and a lazy remote-connect +// route. This suite only asserts whether the strip mounts it at all. +vi.mock('@/features/dispatch/DispatchTargetPicker', () => ({ + DispatchTargetPicker: () =>
, +})); + describe('ChatInputWorkspaceStrip git refresh behavior', () => { let container: HTMLDivElement; let root: Root; @@ -353,4 +359,81 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { expect(container.querySelector('[data-testid="chat-input-worktree-toggle"]')).toBeNull(); }); + + it('shows the dispatch picker and the worktree toggle together in a Git workspace', async () => { + await act(async () => { + root.render( + + ); + }); + + expect(container.querySelector('[data-testid="chat-input-worktree-toggle"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="chat-input-dispatch-trigger"]')).not.toBeNull(); + }); + + it('shows the dispatched branch instead of the source branch once dispatch is locked', async () => { + await act(async () => { + root.render( + + ); + }); + + expect(container.textContent).toContain('bitfun/dispatch/job-1'); + expect(container.textContent).not.toContain('main'); + }); + + it('hides the dispatch picker outside a Git workspace, like the worktree toggle', async () => { + mocks.useGitState.mockReturnValue({ + currentBranch: '', + isRepository: false, + refreshBasic: mocks.refreshBasic, + }); + + await act(async () => { + root.render( + + ); + }); + + expect(container.querySelector('[data-testid="chat-input-worktree-toggle"]')).toBeNull(); + expect(container.querySelector('[data-testid="chat-input-dispatch-trigger"]')).toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index f380144914..33d896152c 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -7,9 +7,9 @@ import { useTranslation } from 'react-i18next'; import { Activity, Check, - Download, EyeOff, GitBranch, + RefreshCw, Shield, ShieldAlert, ShieldCheck, @@ -23,6 +23,7 @@ import { useGitState } from '@/tools/git/hooks/useGitState'; import type { SessionExecutionTarget } from '@/infrastructure/api/service-api/WorktreeAPI'; import { useI18n } from '@/infrastructure/i18n'; import { DispatchResultDialog } from '@/features/dispatch/DispatchResultDialog'; +import { DispatchTargetPicker } from '@/features/dispatch/DispatchTargetPicker'; import type { DispatchSelection, DispatchTarget } from '@/features/dispatch/types'; import './ChatInputWorkspaceStrip.scss'; @@ -65,6 +66,8 @@ export interface ChatInputWorkspaceStripProps { enabled: boolean; /** Locked once the session has a transcript — its history describes one directory. */ locked: boolean; + /** Why the control is locked, when a transcript is not the reason. */ + lockedReason?: 'dispatch'; onChange: (enabled: boolean) => void; }; /** Immutable per-session dispatch destination. Hidden on embedded/mini composers. */ @@ -74,12 +77,11 @@ export interface ChatInputWorkspaceStripProps { locked: boolean; onSelectLocal?: () => void; onSelectTarget: (selection: DispatchSelection) => void; - /** - * Set once a snapshot-delivered job has finished, so its results can be - * reviewed. Absent for local, non-snapshot, or still-running sessions — - * there is nothing to pull in those cases. - */ - completedSnapshotJobId?: string; + /** Target worktree can be committed and synced from running onward. */ + syncableJobId?: string; + branch?: string; + baselineWorktreePath?: string; + baselineMissing?: boolean; }; } @@ -135,14 +137,19 @@ export const ChatInputWorkspaceStrip: React.FC = ( const showUsage = usageReport?.visible && !!usageReport.onOpen; const showGoal = threadGoal?.visible && !!threadGoal.onOpen; const showPermission = !!permissionControl; - const showDispatchResult = !!dispatchControl?.completedSnapshotJobId; - const showRightActions = showDispatchResult || showPermission || showUsage || showGoal; + const showDispatchResult = !!dispatchControl?.syncableJobId; const isWorktree = !!executionTarget?.worktreeId; const worktreeEnabled = worktreeControl?.enabled ?? isWorktree; const worktreeEnabledRef = useRef(worktreeEnabled); worktreeEnabledRef.current = worktreeEnabled; - const showWorktreeToggle = - !!worktreeControl && (isRepository || isWorktree || worktreeEnabled); + // Dispatch delivers work as a Git worktree of the controller's repository, so + // it is only meaningful where a worktree itself is — the same condition the + // isolation toggle uses, evaluated from the same Git probe. + const isGitWorkspace = isRepository || isWorktree || worktreeEnabled; + const showWorktreeToggle = !!worktreeControl && isGitWorkspace; + const showDispatchPicker = !!dispatchControl && isGitWorkspace; + const showRightActions = + showDispatchPicker || showDispatchResult || showPermission || showUsage || showGoal; const permissionCopy = { ask: { label: t('chatInput.permissionMode.ask.label'), @@ -188,19 +195,25 @@ export const ChatInputWorkspaceStrip: React.FC = ( }; }, [permissionMenuOpen]); + const dispatchBranch = dispatchControl?.locked + && worktreeControl?.lockedReason === 'dispatch' + ? dispatchControl?.branch?.trim() + : undefined; const branchTooltipContent = useMemo( () => - isRepository && currentBranch?.trim() + dispatchBranch + || (isRepository && currentBranch?.trim() ? currentBranch.trim() - : t('workspaceStrip.branchTooltipUnavailable'), - [currentBranch, isRepository, t], + : t('workspaceStrip.branchTooltipUnavailable')), + [currentBranch, dispatchBranch, isRepository, t], ); if (!label && !showRightActions) { return null; } - const branchLabel = executionTarget?.branch?.trim() + const branchLabel = dispatchBranch + || executionTarget?.branch?.trim() || (isWorktree && currentBranch?.trim()) || (isWorktree && executionTarget?.baseCommit ? tWorktrees('labels.detached', { commit: executionTarget.baseCommit.slice(0, 9) }) @@ -211,7 +224,9 @@ export const ChatInputWorkspaceStrip: React.FC = ( const workspaceTooltipContent = trimmedPath || label; const worktreeToggleDisabled = !!worktreeControl?.locked; let worktreeTooltip = tWorktrees('strip.toggleOffDescription'); - if (worktreeControl?.locked) { + if (worktreeControl?.lockedReason === 'dispatch') { + worktreeTooltip = tWorktrees('strip.dispatchBaseline'); + } else if (worktreeControl?.locked) { worktreeTooltip = tWorktrees('strip.toggleLocked'); } else if (worktreeEnabled && !isWorktree) { worktreeTooltip = tWorktrees('strip.togglePendingOnDescription'); @@ -325,28 +340,34 @@ export const ChatInputWorkspaceStrip: React.FC = ( {showRightActions ? (
- {/* - * 0.2.15 release gate: dispatch session creation stays hidden while - * its lifecycle semantics stabilize. Restore DispatchTargetPicker - * here in a later release; existing result review remains available. - */} - {dispatchControl?.completedSnapshotJobId ? ( + {showDispatchPicker && dispatchControl ? ( + + ) : null} + {dispatchControl?.syncableJobId ? ( <> - + { expect(stylesheet).toContain('display: none;'); }); - it('keeps dispatch session creation hidden for the 0.2.15 release', () => { + it('mounts the dispatch picker behind the same Git gate as worktree isolation', () => { const component = readWorkspaceStripComponent(); - expect(component).toContain('0.2.15 release gate'); - expect(component).toContain('Restore DispatchTargetPicker'); - expect(component).not.toContain(' { title: 'Remote task', agentType: 'agentic', approvalPolicy: 'remote', - workspaceDelivery: { kind: 'existing' }, cursor: 0, state: 'running', appliedEventIds: [], diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index 2ed491ccd8..4a79733af0 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -133,6 +133,79 @@ describe('dispatch optimistic turn reconciliation', () => { expect(turns?.[0]?.userMessage.metadata) .not.toHaveProperty('__bitfunOptimisticDispatchJobId'); }); + + it('hydrates the real prompt into an audit-only dispatch placeholder', () => { + FlowChatStore.getInstance().setState(() => ({ + sessions: new Map([[ + 'dispatch-session', + { + sessionId: 'dispatch-session', + title: 'Remote task title', + dialogTurns: [{ + id: 'dispatch_pending_job-1', + sessionId: 'dispatch-session', + agentType: 'agentic', + userMessage: { + id: 'user-dispatch-1', + content: '', + timestamp: 1000, + metadata: markOptimisticDispatchTurnMetadata(undefined, 'job-1'), + }, + modelRounds: [{ + id: 'dispatch-setup:job-1', + index: -1, + items: [], + isStreaming: false, + isComplete: true, + status: 'completed', + startTime: 1000, + endTime: 1000, + }], + status: 'pending', + startTime: 1000, + }], + status: 'idle', + config: { + dispatchTarget: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target/repo', + displayName: 'build-host', + }, + dispatchJobId: 'job-1', + }, + createdAt: 1000, + lastActiveAt: 1000, + error: null, + sessionKind: 'normal', + } as Session, + ]]), + activeSessionId: 'dispatch-session', + })); + + __test_only__.handleDialogTurnStarted(createFlowChatContext(), { + sessionId: 'dispatch-session', + turnId: 'target-turn-1', + turnIndex: 0, + userInput: 'Expanded target prompt', + originalUserInput: 'Original user prompt', + userMessageMetadata: { targetFact: true }, + }); + + const turns = FlowChatStore.getInstance() + .getState() + .sessions.get('dispatch-session') + ?.dialogTurns; + expect(turns).toHaveLength(1); + expect(turns?.[0]).toMatchObject({ + id: 'target-turn-1', + userMessage: { + content: 'Original user prompt', + metadata: { targetFact: true }, + }, + modelRounds: [{ id: 'dispatch-setup:job-1' }], + }); + }); }); describe('mergeParamsPartialEventData', () => { @@ -950,4 +1023,37 @@ describe('handleDialogTurnComplete', () => { expect(turn?.finishReason).toBe('max_rounds'); expect(turn?.hasFinalResponse).toBe(false); }); + + it('preserves the event duration when the quiet completion finalizer runs later', async () => { + putFinishingSessionInStore(); + const context = createFlowChatContext(); + await setFinishingMachine(); + + handleDialogTurnComplete(context, { + sessionId: 'session-1', + turnId: 'turn-1', + durationMs: 21_206, + success: true, + finishReason: 'stop', + hasFinalResponse: true, + }, vi.fn()); + + const eventOwnedEndTime = 900 + 21_206; + expect(FlowChatStore.getInstance() + .getState() + .sessions.get('session-1') + ?.dialogTurns[0].endTime).toBe(eventOwnedEndTime); + + handleSessionStateChanged(context, { + sessionId: 'session-1', + newState: 'Idle', + }); + + const finalizedTurn = FlowChatStore.getInstance() + .getState() + .sessions.get('session-1') + ?.dialogTurns[0]; + expect(finalizedTurn?.status).toBe('completed'); + expect(finalizedTurn?.endTime).toBe(eventOwnedEndTime); + }); }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index a4d2608c9f..926e0dadcc 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -1064,6 +1064,7 @@ function finalizeTurnCompletionState( context.flowChatStore.markSessionFinished(sessionId); context.flowChatStore.updateDialogTurn(sessionId, turnId, turn => { + const completedAt = Date.now(); const updatedModelRounds = turn.modelRounds.map((round) => { if (round.isStreaming) { return { @@ -1071,7 +1072,7 @@ function finalizeTurnCompletionState( isStreaming: false, isComplete: true, status: 'completed' as const, - endTime: Date.now() + endTime: round.endTime ?? completedAt }; } return round; @@ -1081,7 +1082,7 @@ function finalizeTurnCompletionState( ...turn, modelRounds: updatedModelRounds, status: 'completed' as const, - endTime: Date.now() + endTime: turn.endTime ?? completedAt }; }); reconcileBackgroundSubagentSession(sessionId); @@ -2302,6 +2303,7 @@ export function handleDialogTurnComplete( const success = event?.success; const finishReason = event?.finishReason ?? event?.finish_reason; const hasFinalResponse = event?.hasFinalResponse ?? event?.has_final_response; + const durationMs = optionalNumber(event?.durationMs ?? event?.duration_ms); if (!sessionId || !turnId) { log.warn('DialogTurnCompleted missing sessionId or turnId', { event }); @@ -2348,6 +2350,9 @@ export function handleDialogTurnComplete( return { ...turn, status: 'finishing' as const, + endTime: durationMs === undefined + ? turn.endTime + : turn.startTime + Math.max(0, durationMs), success: success ?? undefined, finishReason: finishReason ?? undefined, hasFinalResponse: typeof hasFinalResponse === 'boolean' ? hasFinalResponse : undefined, 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 4c62d28944..a54508c429 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 @@ -474,10 +474,7 @@ describe('MessageModule detached dispatch', () => { it('projects the user message immediately while the target is still queued', async () => { const { context, session } = createDispatchContext('reject-and-report'); - (session.config as any).dispatchWorkspaceDelivery = { - kind: 'snapshot-source', - sourceWorkspacePath: '/controller/repo', - }; + session.config.dispatchIncludeUncommitted = true; let resolveSubmit!: (value: { accepted: boolean; jobId: string; @@ -529,10 +526,8 @@ describe('MessageModule detached dispatch', () => { connectionId: 'ssh-1', workspacePath: '/target/repo', }, - workspaceDelivery: { - kind: 'snapshot-source', - sourceWorkspacePath: '/controller/repo', - }, + includeUncommitted: true, + baseRef: 'HEAD', jobId: 'job-1', sessionId: 'dispatch-session', agentType: 'agentic', 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 0f4f0672de..9d6c0d8ce5 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 @@ -410,34 +410,27 @@ export async function sendMessage( 'MessageModule', ); - const workspaceDelivery = - readySession.config.dispatchWorkspaceDelivery ?? { kind: 'existing' as const }; - const sourceWorkspacePath = - sessionProjectWorkspacePath(readySession) - || ( - workspaceDelivery.kind === 'snapshot-source' - || workspaceDelivery.kind === 'snapshot-exact' - ? workspaceDelivery.sourceWorkspacePath - : undefined - ); + const includeUncommitted = readySession.config.dispatchIncludeUncommitted ?? false; + const baseRef = readySession.config.dispatchBaseRef?.trim() || 'HEAD'; + const sourceWorkspacePath = sessionProjectWorkspacePath(readySession); const sourceWorkspaceId = readySession.workspaceId || readySession.config.workspaceId; const transferRoundId = `dispatch-transfer:${jobId}`; + // Provisioning is always more than a submit now — a worktree is created, + // the target checks out the baseline, and objects may be transferred — + // so the transfer label always applies. showRuntimeStatus({ sessionId, turnId: optimisticTurnId, roundId: transferRoundId, - label: i18nService.t( - workspaceDelivery.kind === 'existing' - ? 'flow-chat:chatInput.dispatch.submissionInProgress' - : 'flow-chat:chatInput.dispatch.transferInProgress', - ), + label: i18nService.t('flow-chat:chatInput.dispatch.transferInProgress'), }); let response: Awaited>; try { response = await dispatchApi.submit({ target: targetRequest, - workspaceDelivery, + baseRef, + includeUncommitted, jobId, sessionId, agentType: currentAgentType, 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 80b0fe60d6..ce1836a4cf 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 @@ -767,7 +767,6 @@ export async function createChatSession( if (!approvalPolicy) { throw new Error('Dispatch approval policy must be selected before creating a session'); } - const workspaceDelivery = config.dispatchWorkspaceDelivery ?? { kind: 'existing' as const }; const resolvedConfig: SessionConfig = { ...config, // A dispatch projection must not inherit or resolve a controller-side @@ -780,7 +779,8 @@ export async function createChatSession( dispatchTarget, dispatchJobId: jobId, dispatchApprovalPolicy: approvalPolicy, - dispatchWorkspaceDelivery: workspaceDelivery, + dispatchIncludeUncommitted: config.dispatchIncludeUncommitted ?? false, + dispatchBaseRef: config.dispatchBaseRef?.trim() || 'HEAD', dispatchJobState: 'submitting', dispatchCursor: 0, }; @@ -809,7 +809,6 @@ export async function createChatSession( title: sessionName, agentType, approvalPolicy, - workspaceDelivery, // 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, 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 eb5e140176..c9bbf7f0e2 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -3,6 +3,7 @@ import { flowChatStore, mergeModelRoundAttemptDiagnostics } from './FlowChatStor import type { FlowChatState, Session } from '../types/flow-chat'; import { startupTrace } from '@/shared/utils/startupTrace'; import { projectEffectiveToolItem } from '../utils/toolInvocationIdentity'; +import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; const apiMocks = vi.hoisted(() => ({ listSessions: vi.fn(), @@ -136,6 +137,7 @@ const resetStore = () => { sessions: new Map(), activeSessionId: null, })); + dispatchJobStore.getState().clear(); flowChatStore.registerPersistUnreadCompletionCallback(() => {}); }; @@ -236,6 +238,9 @@ describe('FlowChatStore dispatch observer boundaries', () => { it('accepts a canonical workspace path without allowing target identity changes', () => { const session = createSession({ workspacePath: '/source', + isHistorical: true, + historyState: 'metadata-only', + contextRestoreState: 'pending', config: { dispatchTargetRequest: { kind: 'ssh', @@ -281,6 +286,11 @@ describe('FlowChatStore dispatch observer boundaries', () => { workspacePath: '/home/user/repo', displayName: 'renamed-host', }); + expect(flowChatStore.getState().sessions.get(session.sessionId)).toMatchObject({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + }); flowChatStore.updateSessionDispatchTarget(session.sessionId, { targetRequest: { @@ -1964,6 +1974,147 @@ describe('FlowChatStore historical session hydration state', () => { }); }); + it('never sends a dispatch observer projection through local history restore', async () => { + flowChatStore.setState(() => ({ + sessions: new Map([ + ['session-1', createSession({ + sessionId: 'session-1', + isHistorical: true, + historyState: 'metadata-only', + config: { + agentType: 'agentic', + dispatchTarget: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target', + displayName: 'build-host', + }, + dispatchJobId: 'job-1', + }, + })], + ]), + activeSessionId: 'session-1', + })); + + await flowChatStore.loadSessionHistory('session-1', '/source'); + + expect(apiMocks.accountFetchSessionTurns).not.toHaveBeenCalled(); + expect(apiMocks.restoreSessionView).not.toHaveBeenCalled(); + expect(apiMocks.restoreSessionWithTurns).not.toHaveBeenCalled(); + expect(apiMocks.restoreSession).not.toHaveBeenCalled(); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + expect(flowChatStore.getState().sessions.get('session-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + }); + }); + + it('preserves a dispatch transcript when an earlier local restore resolves late', async () => { + const restore = createDeferred<{ + session: { + sessionId: string; + sessionName: string; + agentType: string; + state: string; + turnCount: number; + createdAt: number; + }; + turns: any[]; + contextRestoreState: 'pending'; + }>(); + apiMocks.restoreSessionView.mockReturnValueOnce(restore.promise); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['session-1', createSession({ + sessionId: 'session-1', + workspacePath: '/source', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'session-1', + })); + + const load = flowChatStore.loadSessionHistory('session-1', '/source'); + await vi.waitFor(() => { + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + }); + + flowChatStore.updateSessionDispatchTarget('session-1', { + targetRequest: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target', + }, + target: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target', + displayName: 'build-host', + }, + jobId: 'job-1', + approvalPolicy: 'reject-and-report', + cursor: 120, + sourceWorkspacePath: '/source', + }); + expect(flowChatStore.hydrateDispatchTranscript('session-1', [{ + id: 'turn-cached', + sessionId: 'session-1', + userMessage: { + id: 'user-cached', + content: 'run task', + timestamp: 1, + }, + modelRounds: [{ + id: 'round-cached', + index: 0, + items: [{ + id: 'text-cached', + type: 'text', + content: 'cached body', + status: 'completed', + isStreaming: false, + timestamp: 2, + }], + isStreaming: false, + isComplete: true, + status: 'completed', + startTime: 1, + endTime: 2, + }], + status: 'completed', + startTime: 1, + endTime: 2, + } as any])).toBe(true); + + restore.resolve({ + session: { + sessionId: 'session-1', + sessionName: 'Saved local shell', + agentType: 'agentic', + state: 'Idle', + turnCount: 0, + createdAt: 1, + }, + turns: [], + contextRestoreState: 'pending', + }); + await load; + + expect(flowChatStore.getState().sessions.get('session-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + dialogTurns: [{ + id: 'turn-cached', + modelRounds: [{ items: [{ content: 'cached body' }] }], + }], + }); + expect(stateMachineManagerMock.getOrCreate).not.toHaveBeenCalled(); + expect(stateMachineManagerMock.reset).not.toHaveBeenCalled(); + }); + it('merges restored session model selection into an existing subagent shell', async () => { apiMocks.restoreSessionView.mockResolvedValueOnce({ session: { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 8c1f23e1dd..4e426710b3 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -76,6 +76,17 @@ import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; const log = createLogger('FlowChatStore'); +function dispatchObserverOwnsSession( + sessionId: string, + session?: Session, +): boolean { + if (isNonLocalDispatchTarget(session?.config.dispatchTarget)) { + return true; + } + return Object.values(dispatchJobStore.getState().jobs) + .some(job => job.sessionId === sessionId); +} + function logPersistedDispatchMetadataOverlap( metadata: Record, source: 'metadata-page' | 'metadata-list', @@ -2284,6 +2295,12 @@ export class FlowChatStore { defaultModel?: string; state?: NonNullable; cursor?: number; + /** + * Observer recovery may deliberately resume from a transcript cache that + * trails the renderer cursor persisted before shutdown. Only that paired + * cache/replay path may move the projection cursor backwards. + */ + cursorReset?: boolean; sourceWorkspacePath?: string; sourceWorkspaceId?: string; }, @@ -2311,6 +2328,16 @@ export class FlowChatStore { const sourceWorkspaceId = binding.sourceWorkspaceId?.trim() || undefined; newSessions.set(sessionId, { ...session, + // Observer projections are reconstructed from the target event log, + // never from the local session-history API. Reclassifying a startup + // metadata row here prevents a later click from hydrating empty local + // history over a dispatch transcript restored by the observer. + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + isPartial: false, + loadedTurnCount: session.dialogTurns.length, + totalTurnCount: session.dialogTurns.length, workspacePath: sourceWorkspacePath ?? session.workspacePath, projectWorkspacePath: sourceWorkspacePath ?? session.projectWorkspacePath, @@ -2332,7 +2359,9 @@ export class FlowChatStore { dispatchDefaultModel: binding.defaultModel ?? session.config.dispatchDefaultModel, dispatchJobState: binding.state ?? session.config.dispatchJobState ?? 'queued', - dispatchCursor: Math.max(0, binding.cursor ?? session.config.dispatchCursor ?? 0), + dispatchCursor: binding.cursorReset + ? Math.max(0, binding.cursor ?? 0) + : Math.max(0, binding.cursor ?? session.config.dispatchCursor ?? 0), }, lastActiveAt: Date.now(), }); @@ -2373,9 +2402,12 @@ export class FlowChatStore { const newSessions = new Map(prev.sessions); newSessions.set(sessionId, { ...session, - // `historyState` deliberately stays as `addExternalSession` left it. - // An observer projection has no local history to lazily hydrate, and - // the full-replay path does not move it either. + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + isPartial: false, + loadedTurnCount: turns.length, + totalTurnCount: turns.length, dialogTurns: [...turns].sort(compareDialogTurnOrder), }); hydrated = true; @@ -4851,6 +4883,54 @@ export class FlowChatStore { sessionTraceId, }); const initialSession = this.state.sessions.get(sessionId); + const preserveDispatchObserverProjection = (): boolean => { + const latestSession = this.state.sessions.get(sessionId); + if (!dispatchObserverOwnsSession(sessionId, latestSession)) { + return false; + } + + // If the observer has already bound the target, make its ownership + // explicit. If only the durable job index is present, leave the metadata + // placeholder untouched until ensureProjection supplies the full target. + if (isNonLocalDispatchTarget(latestSession?.config.dispatchTarget)) { + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if ( + !session + || !isNonLocalDispatchTarget(session.config.dispatchTarget) + ) { + return prev; + } + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, { + ...session, + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + isPartial: false, + loadedTurnCount: session.dialogTurns.length, + totalTurnCount: session.dialogTurns.length, + }); + return { ...prev, sessions: newSessions }; + }); + } + return true; + }; + const finishDispatchObserverSkip = (stage: 'initial' | 'late' | 'failed'): void => { + startupTrace.markPhase('historical_session_hydrate_end', { + remote, + sessionId, + sessionTraceId, + skipped: true, + reason: 'dispatch-observer-owned', + stage, + durationMs: elapsedMs(traceStartedAt), + }); + }; + if (preserveDispatchObserverProjection()) { + finishDispatchObserverSkip('initial'); + return; + } // The caller remains authoritative for legacy and remote sessions. Only a // persisted dual-root binding may redirect history storage to the project // root; otherwise a stale in-memory execution path can cross workspaces. @@ -5093,7 +5173,16 @@ export class FlowChatStore { durationMs: elapsedMs(turnsLoadStartedAt), }); } - const { stateMachineManager, SessionExecutionEvent } = await stateMachineManagerPromise; + const stateMachineModule = await stateMachineManagerPromise; + // A local restore may have started just before the observer bound this + // session. Re-check ownership after every restore await and before any + // commit or state-machine mutation so that late empty history cannot + // overwrite a reconstructed dispatch transcript. + if (preserveDispatchObserverProjection()) { + finishDispatchObserverSkip('late'); + return; + } + const { stateMachineManager, SessionExecutionEvent } = stateMachineModule; stateMachineManager.getOrCreate(sessionId); startupTrace.markPhase('historical_session_turns_loaded', { remote, @@ -5278,6 +5367,13 @@ export class FlowChatStore { } } } catch (error) { + // The same race can fail instead of resolving. Once dispatch owns the + // session, that stale local failure must not relabel its projection as a + // failed historical session or surface an irrelevant restore error. + if (preserveDispatchObserverProjection()) { + finishDispatchObserverSkip('failed'); + return; + } this.setState(prev => { const session = prev.sessions.get(sessionId); if (!session) return prev; 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 a5982a1dd0..05e005be25 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -501,8 +501,10 @@ export interface SessionConfig { dispatchJobId?: string; /** Explicit unattended permission behavior selected before submission. */ dispatchApprovalPolicy?: import('@/features/dispatch/types').DispatchApprovalPolicy; - /** Explicit code delivery mode selected before submission. */ - dispatchWorkspaceDelivery?: import('@/features/dispatch/types').DispatchWorkspaceDeliveryRequest; + /** Carry the baseline worktree's uncommitted changes into the base commit. */ + dispatchIncludeUncommitted?: boolean; + /** Git revision used to create the immutable dispatch baseline. */ + dispatchBaseRef?: string; /** Target model explicitly selected during preflight; omitted to use the target default. */ dispatchModel?: string; /** Model ids reported by the selected target during dispatch preflight. */ 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 f52e214c0f..00a52e36f7 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 @@ -62,6 +62,7 @@ const LOCAL_ONLY_COMMANDS = new Set([ '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', @@ -71,6 +72,7 @@ const LOCAL_ONLY_COMMANDS = new Set([ 'dispatch_list_jobs', 'dispatch_answer', 'dispatch_append', + 'dispatch_sync_result', 'dispatch_load_transcript', 'dispatch_save_transcript', 'remote_connect_get_device_info', diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 1762738da7..1efabd1751 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -1458,44 +1458,26 @@ }, "dispatch": { "configureTitle": "Prepare {{target}}", - "configureSubtitle": "Confirm the workspace source, target readiness, and unattended approval policy.", + "configureSubtitle": "Confirm the Git baseline, target readiness, and unattended approval policy.", "readinessTitle": "Target readiness", - "deliveryTitle": "Workspace delivery", - "deliveryExisting": "Use target directory", - "deliveryExistingDescription": "Run against a directory that already exists on the target.", - "deliverySourceSnapshot": "Transfer source snapshot", - "deliverySourceSnapshotDescription": "Copy source files once while excluding ignored dependencies, build output, and local secrets.", - "deliverySnapshot": "Transfer exact snapshot", - "deliverySnapshotDescription": "Copy this workspace once, including hidden and ignored files.", - "deliverySnapshotUnavailable": "Exact snapshots require an open local workspace; remote workspaces cannot be captured by this controller.", - "snapshotSource": "Source workspace", - "sourceSnapshotHint": "Repository ignore rules are honored, so generated output, dependency caches, and ignored secrets are not transferred. Hidden source files remain included.", - "snapshotWarning": "The snapshot includes ignored and hidden regular files. Git metadata is excluded; links and special files are rejected. Changes made on the target are not synced back automatically.", - "snapshotConfirm": "I understand that ignored files may contain secrets and approve this one-time transfer.", - "snapshotResultLocation": "Where results stay", - "snapshotResultLocationHint": "The job runs in ~/.bitfun/dispatch/workspaces//current/ on the target. Results stay there and are never synced back automatically.", + "deliveryTitle": "Git worktree baseline", + "baselineSource": "Source repository", + "baselineDescription": "BitFun creates a managed worktree as an isolated baseline. The target checks out the same commit and works on its own dispatch branch.", + "baseRef": "Base revision", + "baseRefHint": "Defaults to HEAD. You can enter a branch, tag, or commit that exists in this repository.", + "baseRefInvalid": "Could not resolve \"{{ref}}\" in the source repository. Check the branch, tag, or commit and try again.", + "includeUncommitted": "Include uncommitted Git-visible changes", + "includeUncommittedHint": "Changes accepted by git add -A are committed into the baseline. Ignored files such as local .env files and build output are never transferred.", "sourceBuildTitle": "Build from source", "sourceBuildDescription": "Compile the controller-matched source ({{ref}}) on the target. Compatibility is verified by capabilities instead of guessed from the version label; this takes a while.", "sourceBuildConfirm": "Build from source", "sourceBuildConfirmTitle": "Build the BitFun CLI from source on this target?", "sourceBuildConfirmMessage": "The selected source will be transferred or cloned on the target and built with cargo build --release. Needs about 6 GB free and can take tens of minutes.", - "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", "modelMatchesLocal": "Ready (same as this device · {{model}})", "modelDiffersFromLocal": "Ready (differs from this device · {{count}} models on target)", @@ -1508,13 +1490,10 @@ "syncModelConfirmMessage": "This device's model catalog and default model selections, including API credentials, will be written to the target user's BitFun config file with owner-only permissions.", "syncModelConfirm": "Sync", "syncingModel": "Syncing…", - "installRequired": "Install or update BitFun CLI", - "installDescription": "BitFun will install the verified release in the target user's home directory.", + "installAutomaticTitle": "Automatic CLI installation", + "installAutomaticDescription": "When you send the task, BitFun will install this signed release automatically, verify its SHA256 digest, and record the action in the dispatch audit log.", "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", @@ -1526,7 +1505,7 @@ "approvalRemote": "Ask this device", "approvalRemoteDescription": "Pause the target task and answer permission requests from this observer.", "approvalAuto": "Auto approve", - "approvalAutoDescription": "Automatically approve permission requests on the target. You will confirm again before sending.", + "approvalAutoDescription": "Automatically approve permission requests on the target for this dispatched task. Sending the task applies this policy.", "useTarget": "Use this target", "cancel": "Cancel", "eventHistoryIncomplete": "Some dispatched task events were omitted or expired. The visible transcript may be incomplete.", @@ -1536,25 +1515,21 @@ "permissionTitle": "Dispatched task needs approval", "permissionBody": "{{task}} has {{count}} permission request(s) waiting.", "localTarget": "this computer", - "resultTitle": "Dispatch results", - "resultSubtitle": "Review what changed on the target before any of it reaches your workspace.", - "resultSubtitleWithTarget": "Review what changed on {{target}} before any of it reaches your workspace.", - "resultPulling": "Pulling results from the target…", - "resultTargetWorkspace": "Target workspace", - "resultNoChanges": "This job changed no files.", - "resultAdded": "Added", - "resultModified": "Modified", - "resultDeleted": "Deleted", - "resultApply": "Apply locally", - "resultApplied": "Applied: {{written}} written, {{removed}} removed.", - "resultClose": "Close", - "resultConflicts": "Conflicts", - "resultConflictWarning": "{{count}} file(s) also changed locally. Nothing was written.", - "resultConflictModified": "modified locally", - "resultConflictMissing": "missing locally", - "resultOverwriteTitle": "Overwrite your local changes with the target's?", - "resultOverwriteMessage": "These files changed both locally and on the target. Continuing discards your local versions and cannot be undone.", - "resultOverwriteConfirm": "Overwrite local changes" + "syncTitle": "Sync dispatch branch", + "syncSubtitle": "Commit the target worktree and fetch its branch into the managed baseline worktree.", + "syncSubtitleWithTarget": "Commit the worktree on {{target}} and fetch its branch into the managed baseline worktree.", + "syncBranch": "Dispatch branch", + "syncBaselineWorktree": "Baseline worktree", + "syncBaselineMissing": "The managed baseline worktree is missing. This dispatch can no longer be synced automatically.", + "syncingResult": "Committing and transferring the dispatch branch…", + "syncSucceeded": "Synced {{count}} commit(s) into the baseline worktree.", + "syncHeadCommit": "Synced head commit", + "syncChangedFiles": "Changed files", + "syncNoFileList": "The commit was synced, but the target did not return a file list.", + "syncChangesTruncated": "Only part of the changed-file list is shown. The full Git history was synced.", + "syncNoChanges": "The target worktree still matches the baseline; there is nothing to sync.", + "syncAction": "Sync to baseline", + "syncClose": "Close" }, "collapse": "Collapse", "expand": "Expand", 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 d1ff87149d..f1e0229657 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -650,8 +650,13 @@ "deviceOffline": "Offline", "createFailed": "Could not create the dispatched task.", "remoteTarget": "the remote target", - "submissionInProgress": "Submitting the task to the target…", - "transferInProgress": "Preparing and transferring the workspace to the target…" + "transferInProgress": "Creating the baseline worktree, fetching the repository, and transferring missing Git objects…", + "cliInstallStarted": "Installing verified BitFun CLI {{version}} for {{target}} on the SSH target.", + "cliInstallSucceeded": "Verified BitFun CLI {{version}} is ready on the SSH target.", + "cliInstallFailed": "BitFun CLI installation failed on the SSH target: {{details}}", + "cliInstallStage": "SSH target CLI setup: {{stage}}", + "cliInstallUnknownVersion": "release", + "cliInstallUnknownStage": "status update" }, "addBoostTooltip": "Agent modes, image, or skills", "permissionMode": { diff --git a/src/web-ui/src/locales/en-US/worktrees.json b/src/web-ui/src/locales/en-US/worktrees.json index f5799f38b9..82b863e056 100644 --- a/src/web-ui/src/locales/en-US/worktrees.json +++ b/src/web-ui/src/locales/en-US/worktrees.json @@ -9,6 +9,7 @@ "togglePendingOnDescription": "Worktree isolation is armed. The worktree will be created after you send the first message.", "togglePendingOffDescription": "Worktree isolation will be turned off after you send the first message.", "toggleLocked": "Worktree isolation can only be changed before the session's first message.", + "dispatchBaseline": "This dispatch runs against a managed worktree baseline of this repository. The baseline is fixed when the target is chosen.", "retained": "The worktree still held local work and was kept at {{path}}." }, "settings": { diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 957986cd44..3d08119f50 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -1458,44 +1458,26 @@ }, "dispatch": { "configureTitle": "准备 {{target}}", - "configureSubtitle": "确认工作区来源、目标就绪状态与无人值守权限策略。", + "configureSubtitle": "确认 Git 基线、目标就绪状态与无人值守权限策略。", "readinessTitle": "目标就绪状态", - "deliveryTitle": "工作区传输", - "deliveryExisting": "使用目标目录", - "deliveryExistingDescription": "在目标上已存在的目录中运行。", - "deliverySourceSnapshot": "传输源码快照", - "deliverySourceSnapshotDescription": "一次性复制源码,并排除被忽略的依赖、构建产物和本地密钥。", - "deliverySnapshot": "传输精确快照", - "deliverySnapshotDescription": "一次性复制当前工作区,包括隐藏文件和被忽略文件。", - "deliverySnapshotUnavailable": "精确快照需要打开本地工作区;此派发端无法抓取远程工作区。", - "snapshotSource": "源工作区", - "sourceSnapshotHint": "遵循仓库忽略规则,不传输生成产物、依赖缓存和被忽略的密钥;隐藏的源码文件仍会包含。", - "snapshotWarning": "快照包含被忽略及隐藏的普通文件;不包含 Git 元数据;符号链接和特殊文件会被拒绝。目标上的修改不会自动同步回来。", - "snapshotConfirm": "我了解被忽略文件可能包含密钥,并同意本次一次性传输。", - "snapshotResultLocation": "结果存放位置", - "snapshotResultLocationHint": "任务在目标上的 ~/.bitfun/dispatch/workspaces/<任务ID>/current/ 中执行。结果保留在目标上,不会自动同步回本地。", + "deliveryTitle": "Git worktree 基线", + "baselineSource": "源代码仓库", + "baselineDescription": "BitFun 会创建一个受管 worktree 作为隔离基线;目标端检出同一个 commit,并在独立的派发分支上工作。", + "baseRef": "基准版本", + "baseRefHint": "默认为 HEAD,也可填写此仓库中存在的分支、标签或 commit。", + "baseRefInvalid": "无法在源代码仓库中解析“{{ref}}”。请检查分支、标签或 commit 后重试。", + "includeUncommitted": "包含 Git 可见的未提交改动", + "includeUncommittedHint": "可被 git add -A 纳入的改动会提交到基线中;本地 .env、构建产物等被忽略文件绝不会传输。", "sourceBuildTitle": "从源码编译", "sourceBuildDescription": "在目标上编译与当前控制端一致的源码({{ref}})。兼容性按能力校验,不再仅凭版本号猜测;耗时较长。", "sourceBuildConfirm": "从源码编译", "sourceBuildConfirmTitle": "在此目标上从源码编译 BitFun CLI?", "sourceBuildConfirmMessage": "会把所选源码传输或克隆到目标上,并执行 cargo build --release。需要约 6 GB 可用空间,可能耗时数十分钟。", - "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": "目标模型", "modelMatchesLocal": "就绪(与本机一致 · {{model}})", "modelDiffersFromLocal": "就绪(与本机不同 · 目标有 {{count}} 个模型)", @@ -1508,13 +1490,10 @@ "syncModelConfirmMessage": "本机的模型列表与默认模型选择(包含 API 密钥)将写入目标用户的 BitFun 配置文件,且仅目标用户可读。", "syncModelConfirm": "同步", "syncingModel": "正在同步…", - "installRequired": "安装或更新 BitFun CLI", - "installDescription": "BitFun 会将已验证的发行版安装到目标用户的主目录。", + "installAutomaticTitle": "自动安装 CLI", + "installAutomaticDescription": "发送任务时,BitFun 会自动安装此签名发行版、校验 SHA256 摘要,并把操作写入派发审计日志。", "version": "版本", "downloadUrl": "下载地址", - "installConfirmTitle": "在此目标上安装 BitFun CLI?", - "installConfirmMessage": "将从 {{url}} 下载版本 {{version}},安装前校验 SHA256 {{sha256}}。", - "installConfirm": "安装", "installing": "正在安装…", "installFailed": "CLI 安装失败。请检查输出后重试。", "installOutput": "CLI 安装输出", @@ -1526,7 +1505,7 @@ "approvalRemote": "在本设备询问", "approvalRemoteDescription": "暂停目标任务,并由当前观察端回答权限请求。", "approvalAuto": "自动批准", - "approvalAutoDescription": "自动批准目标上的权限请求。发送前仍会再次确认。", + "approvalAutoDescription": "自动批准此派发任务在目标端产生的权限请求;发送任务即表示采用此策略。", "useTarget": "使用此目标", "cancel": "取消", "eventHistoryIncomplete": "部分派发任务事件已省略或过期,当前会话记录可能不完整。", @@ -1536,25 +1515,21 @@ "permissionTitle": "派发任务需要批准", "permissionBody": "{{task}} 有 {{count}} 个权限请求等待处理。", "localTarget": "本机", - "resultTitle": "派发任务结果", - "resultSubtitle": "查看目标上的改动,确认后再落到本地工作区。", - "resultSubtitleWithTarget": "查看 {{target}} 上的改动,确认后再落到本地工作区。", - "resultPulling": "正在从目标拉取结果…", - "resultTargetWorkspace": "目标工作区", - "resultNoChanges": "本次任务没有修改任何文件。", - "resultAdded": "新增", - "resultModified": "修改", - "resultDeleted": "删除", - "resultApply": "应用到本地", - "resultApplied": "已应用:写入 {{written}} 个文件,删除 {{removed}} 个。", - "resultClose": "关闭", - "resultConflicts": "冲突", - "resultConflictWarning": "有 {{count}} 个文件在本地也被改动过,未写入任何内容。", - "resultConflictModified": "本地已修改", - "resultConflictMissing": "本地已删除或类型不符", - "resultOverwriteTitle": "用目标上的版本覆盖本地改动?", - "resultOverwriteMessage": "这些文件在本地和目标上都有改动。继续会丢弃本地版本,且无法撤销。", - "resultOverwriteConfirm": "覆盖本地改动" + "syncTitle": "同步派发分支", + "syncSubtitle": "提交目标 worktree,并将其分支提取到受管基线 worktree。", + "syncSubtitleWithTarget": "提交 {{target}} 上的 worktree,并将其分支提取到受管基线 worktree。", + "syncBranch": "派发分支", + "syncBaselineWorktree": "基线 worktree", + "syncBaselineMissing": "受管基线 worktree 已不存在,无法再自动同步此派发任务。", + "syncingResult": "正在提交并传输派发分支…", + "syncSucceeded": "已将 {{count}} 个 commit 同步到基线 worktree。", + "syncHeadCommit": "已同步的最新 commit", + "syncChangedFiles": "改动文件", + "syncNoFileList": "commit 已同步,但目标端没有返回文件列表。", + "syncChangesTruncated": "这里只展示部分改动文件;完整 Git 历史已同步。", + "syncNoChanges": "目标 worktree 与基线一致,无需同步。", + "syncAction": "同步到基线", + "syncClose": "关闭" }, "collapse": "折叠", "expand": "展开", 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 96dc9f5960..9cc89b1280 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -650,8 +650,13 @@ "deviceOffline": "离线", "createFailed": "无法创建派发任务。", "remoteTarget": "远程目标", - "submissionInProgress": "正在向目标提交任务…", - "transferInProgress": "正在准备并传输工作区到目标…" + "transferInProgress": "正在创建基线 worktree、拉取代码仓库并传输缺失的 Git 对象…", + "cliInstallStarted": "正在 SSH 目标上安装适用于 {{target}} 的已验证 BitFun CLI {{version}}。", + "cliInstallSucceeded": "已验证的 BitFun CLI {{version}} 已在 SSH 目标上就绪。", + "cliInstallFailed": "SSH 目标上的 BitFun CLI 安装失败:{{details}}", + "cliInstallStage": "SSH 目标 CLI 设置:{{stage}}", + "cliInstallUnknownVersion": "发布版本", + "cliInstallUnknownStage": "状态更新" }, "addBoostTooltip": "智能体模式、图片或 Skill", "permissionMode": { diff --git a/src/web-ui/src/locales/zh-CN/worktrees.json b/src/web-ui/src/locales/zh-CN/worktrees.json index a87f367361..065e9fca65 100644 --- a/src/web-ui/src/locales/zh-CN/worktrees.json +++ b/src/web-ui/src/locales/zh-CN/worktrees.json @@ -9,6 +9,7 @@ "togglePendingOnDescription": "已开启 worktree 隔离;发送第一条消息后才会创建 worktree。", "togglePendingOffDescription": "已关闭 worktree 隔离;发送第一条消息后会回到项目目录。", "toggleLocked": "只能在会话发出第一条消息之前切换 worktree 隔离。", + "dispatchBaseline": "本次派发以该仓库的受管 worktree 作为基线执行。基线在选定目标时即已固定。", "retained": "该 worktree 仍有本地工作,已保留在 {{path}}。" }, "settings": { diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 8c64803453..4e97c2c7cf 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -1458,44 +1458,26 @@ }, "dispatch": { "configureTitle": "準備 {{target}}", - "configureSubtitle": "確認工作區來源、目標就緒狀態與無人值守權限策略。", + "configureSubtitle": "確認 Git 基線、目標就緒狀態與無人值守權限策略。", "readinessTitle": "目標就緒狀態", - "deliveryTitle": "工作區傳輸", - "deliveryExisting": "使用目標目錄", - "deliveryExistingDescription": "在目標上已存在的目錄中執行。", - "deliverySourceSnapshot": "傳輸原始碼快照", - "deliverySourceSnapshotDescription": "一次性複製原始碼,並排除被忽略的依賴、建置產物和本機密鑰。", - "deliverySnapshot": "傳輸精確快照", - "deliverySnapshotDescription": "一次性複製目前工作區,包括隱藏檔案和被忽略檔案。", - "deliverySnapshotUnavailable": "精確快照需要開啟本機工作區;此派發端無法擷取遠端工作區。", - "snapshotSource": "來源工作區", - "sourceSnapshotHint": "遵循儲存庫忽略規則,不傳輸產生的輸出、依賴快取和被忽略的密鑰;隱藏的原始碼檔案仍會包含。", - "snapshotWarning": "快照包含被忽略及隱藏的一般檔案;不包含 Git 中繼資料;符號連結和特殊檔案會被拒絕。目標上的修改不會自動同步回來。", - "snapshotConfirm": "我瞭解被忽略檔案可能包含密鑰,並同意本次一次性傳輸。", - "snapshotResultLocation": "結果存放位置", - "snapshotResultLocationHint": "任務在目標上的 ~/.bitfun/dispatch/workspaces/<任務ID>/current/ 中執行。結果保留在目標上,不會自動同步回本機。", + "deliveryTitle": "Git worktree 基線", + "baselineSource": "原始碼儲存庫", + "baselineDescription": "BitFun 會建立一個受管 worktree 作為隔離基線;目標端簽出同一個 commit,並在獨立的派發分支上工作。", + "baseRef": "基準版本", + "baseRefHint": "預設為 HEAD,也可填寫此儲存庫中存在的分支、標籤或 commit。", + "baseRefInvalid": "無法在原始碼儲存庫中解析「{{ref}}」。請檢查分支、標籤或 commit 後重試。", + "includeUncommitted": "包含 Git 可見的未提交變更", + "includeUncommittedHint": "可被 git add -A 納入的變更會提交到基線中;本機 .env、建置產物等被忽略檔案絕不會傳輸。", "sourceBuildTitle": "從原始碼編譯", "sourceBuildDescription": "在目標上編譯與目前控制端一致的原始碼({{ref}})。相容性按能力驗證,不再僅憑版本號猜測;耗時較長。", "sourceBuildConfirm": "從原始碼編譯", "sourceBuildConfirmTitle": "在此目標上從原始碼編譯 BitFun CLI?", "sourceBuildConfirmMessage": "會把所選原始碼傳輸或複製到目標上,並執行 cargo build --release。需要約 6 GB 可用空間,可能耗時數十分鐘。", - "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": "目標模型", "modelMatchesLocal": "就緒(與本機一致 · {{model}})", "modelDiffersFromLocal": "就緒(與本機不同 · 目標有 {{count}} 個模型)", @@ -1508,13 +1490,10 @@ "syncModelConfirmMessage": "本機的模型清單與預設模型選擇(包含 API 金鑰)將寫入目標使用者的 BitFun 設定檔,且僅目標使用者可讀。", "syncModelConfirm": "同步", "syncingModel": "正在同步…", - "installRequired": "安裝或更新 BitFun CLI", - "installDescription": "BitFun 會將已驗證的發行版安裝到目標使用者的主目錄。", + "installAutomaticTitle": "自動安裝 CLI", + "installAutomaticDescription": "傳送任務時,BitFun 會自動安裝此簽署發行版、驗證 SHA256 摘要,並把操作寫入派發稽核記錄。", "version": "版本", "downloadUrl": "下載位址", - "installConfirmTitle": "在此目標上安裝 BitFun CLI?", - "installConfirmMessage": "將從 {{url}} 下載版本 {{version}},安裝前驗證 SHA256 {{sha256}}。", - "installConfirm": "安裝", "installing": "正在安裝…", "installFailed": "CLI 安裝失敗。請檢查輸出後重試。", "installOutput": "CLI 安裝輸出", @@ -1526,7 +1505,7 @@ "approvalRemote": "在此裝置詢問", "approvalRemoteDescription": "暫停目標任務,並由目前觀察端回答權限要求。", "approvalAuto": "自動核准", - "approvalAutoDescription": "自動核准目標上的權限要求。傳送前仍會再次確認。", + "approvalAutoDescription": "自動核准此派發任務在目標端產生的權限要求;傳送任務即表示採用此策略。", "useTarget": "使用此目標", "cancel": "取消", "eventHistoryIncomplete": "部分派發任務事件已省略或過期,目前工作階段記錄可能不完整。", @@ -1536,25 +1515,21 @@ "permissionTitle": "派發任務需要核准", "permissionBody": "{{task}} 有 {{count}} 個權限要求等待處理。", "localTarget": "本機", - "resultTitle": "派發任務結果", - "resultSubtitle": "檢視目標上的變更,確認後再套用到本機工作區。", - "resultSubtitleWithTarget": "檢視 {{target}} 上的變更,確認後再套用到本機工作區。", - "resultPulling": "正在從目標拉取結果…", - "resultTargetWorkspace": "目標工作區", - "resultNoChanges": "本次任務沒有修改任何檔案。", - "resultAdded": "新增", - "resultModified": "修改", - "resultDeleted": "刪除", - "resultApply": "套用到本機", - "resultApplied": "已套用:寫入 {{written}} 個檔案,刪除 {{removed}} 個。", - "resultClose": "關閉", - "resultConflicts": "衝突", - "resultConflictWarning": "有 {{count}} 個檔案在本機也被變更過,未寫入任何內容。", - "resultConflictModified": "本機已修改", - "resultConflictMissing": "本機已刪除或類型不符", - "resultOverwriteTitle": "用目標上的版本覆蓋本機變更?", - "resultOverwriteMessage": "這些檔案在本機和目標上都有變更。繼續會捨棄本機版本,且無法復原。", - "resultOverwriteConfirm": "覆蓋本機變更" + "syncTitle": "同步派發分支", + "syncSubtitle": "提交目標 worktree,並將其分支擷取到受管基線 worktree。", + "syncSubtitleWithTarget": "提交 {{target}} 上的 worktree,並將其分支擷取到受管基線 worktree。", + "syncBranch": "派發分支", + "syncBaselineWorktree": "基線 worktree", + "syncBaselineMissing": "受管基線 worktree 已不存在,無法再自動同步此派發任務。", + "syncingResult": "正在提交並傳輸派發分支…", + "syncSucceeded": "已將 {{count}} 個 commit 同步到基線 worktree。", + "syncHeadCommit": "已同步的最新 commit", + "syncChangedFiles": "變更檔案", + "syncNoFileList": "commit 已同步,但目標端沒有回傳檔案清單。", + "syncChangesTruncated": "這裡只顯示部分變更檔案;完整 Git 歷史已同步。", + "syncNoChanges": "目標 worktree 與基線一致,無需同步。", + "syncAction": "同步到基線", + "syncClose": "關閉" }, "collapse": "收合", "expand": "展開", 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 9966dde990..be92ec77be 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -650,8 +650,13 @@ "deviceOffline": "離線", "createFailed": "無法建立派發任務。", "remoteTarget": "遠端目標", - "submissionInProgress": "正在向目標提交任務…", - "transferInProgress": "正在準備並傳輸工作區到目標…" + "transferInProgress": "正在建立基線 worktree、擷取程式碼儲存庫並傳輸缺少的 Git 物件…", + "cliInstallStarted": "正在 SSH 目標上安裝適用於 {{target}} 的已驗證 BitFun CLI {{version}}。", + "cliInstallSucceeded": "已驗證的 BitFun CLI {{version}} 已在 SSH 目標上就緒。", + "cliInstallFailed": "SSH 目標上的 BitFun CLI 安裝失敗:{{details}}", + "cliInstallStage": "SSH 目標 CLI 設定:{{stage}}", + "cliInstallUnknownVersion": "發佈版本", + "cliInstallUnknownStage": "狀態更新" }, "addBoostTooltip": "智能體模式、圖片或 Skill", "permissionMode": { diff --git a/src/web-ui/src/locales/zh-TW/worktrees.json b/src/web-ui/src/locales/zh-TW/worktrees.json index a2ad5a74b3..61267fdc06 100644 --- a/src/web-ui/src/locales/zh-TW/worktrees.json +++ b/src/web-ui/src/locales/zh-TW/worktrees.json @@ -9,6 +9,7 @@ "togglePendingOnDescription": "已開啟 worktree 隔離;傳送第一則訊息後才會建立 worktree。", "togglePendingOffDescription": "已關閉 worktree 隔離;傳送第一則訊息後會回到專案目錄。", "toggleLocked": "只能在工作階段送出第一則訊息之前切換 worktree 隔離。", + "dispatchBaseline": "本次派發以該儲存庫的受管 worktree 作為基線執行。基線在選定目標時即已固定。", "retained": "該 worktree 仍有本機工作,已保留在 {{path}}。" }, "settings": {