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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Foundation
/// "Move to Cloud", the tab and chat right-click menus and `cmux cloud move`.
/// For a chat tab of this Mac it reuses a Cloud machine (the named one, else a
/// connected one, else any, else a new one), copies the chat's folder there
/// (`cmux link sync push`), moves the chat (`cmux link agent move`), and shows
/// (`cmux link seed push`), moves the chat (`cmux link agent move`), and shows
/// it on that machine: a Cloud workspace whose terminal runs in the synced
/// folder and whose chat tab is bound to the moved session through the
/// machine's store, so the tab attaches through that machine and carries its
Expand Down Expand Up @@ -226,7 +226,7 @@ struct CloudMove {
for root in services.cloud.move.workRoots {
let remote = folder.remote(under: root)
do {
last = try await cli.syncPush(socket: socket, local: folder.root, remote: remote.folder)
last = try await cli.seedPush(socket: socket, local: folder.root, remote: remote.folder)
} catch {
// Timed out or didn't launch: nothing moved yet, so Retry runs the move again on this machine.
throw Failure(message: CloudMoveStrings.syncFailed(machine, CloudStrings.errorText(error)), retry: retry)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ nonisolated struct CloudMoveCLI: Sendable {
/// The error code of an answer that isn't a JSON line (a crash, a kill).
static let badAnswer = "bad_answer"

/// `cmux link sync push`: copies the working copy `local` to `remote` on
/// the machine whose daemon `socket` reaches.
func syncPush(socket: String, local: String, remote: String) async throws -> Answer {
try await run(["link", "sync", "push", "--socket", socket, local, remote])
/// `cmux link seed push`: seeds `local` at `remote` on the machine `socket` reaches from its
/// GitHub remote plus one bundle, else copies it (`link sync push`), saying why.
func seedPush(socket: String, local: String, remote: String) async throws -> Answer {
try await run(["link", "seed", "push", "--socket", socket, local, remote])
}

/// `cmux link sync pull`: merges the machine's `remote` (what a push left there) back into
Expand All @@ -63,7 +63,7 @@ nonisolated struct CloudMoveCLI: Sendable {
/// The folder copy a move makes once the chat is frozen (`--sync`): what changed since the
/// caller's own sync, so a chat moved mid-turn takes the turn's last changes with it.
struct FrozenSync: Sendable, Equatable {
/// Pull from the machine (Bring Back Local) rather than push to it (Move to Cloud).
/// Pull from the machine (Bring Back Local) rather than seed it (Move to Cloud, `--since-last`).
var pull: Bool
/// The machine link socket `link sync` uses.
var socket: String
Expand All @@ -87,7 +87,7 @@ nonisolated struct CloudMoveCLI: Sendable {

/// `agentMove`'s flags after `--cwd`.
static func agentMoveFlags(sync: FrozenSync?, askFirst: Bool, vmKind: String?, toPID: Int32?) -> [String] {
let frozen = sync.map { ["--sync", $0.pull ? "pull" : "push", "--sync-socket", $0.socket, "--sync-local", $0.local, "--sync-remote", $0.remote] } ?? []
let frozen = sync.map { ["--sync", $0.pull ? "pull" : "seed", "--sync-socket", $0.socket, "--sync-local", $0.local, "--sync-remote", $0.remote] } ?? []
return ["--interrupt"] + frozen + (askFirst ? ["--ask-first"] : []) + (vmKind.map { ["--vm-kind", $0] } ?? [])
+ (toPID.map { ["--to-pid", String($0)] } ?? [])
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ extension CloudHandlers {
do {
cwd = try await CloudNewWorkspacePlan.copy(folder, workRoots: cloud.move.workRoots) { remote in
try checkScope(session)
let answer = try await cli.syncPush(socket: socket, local: folder.root, remote: remote)
let answer = try await cli.seedPush(socket: socket, local: folder.root, remote: remote)
return CloudNewWorkspacePlan.CopyAnswer(ok: answer.ok, errorCode: answer.errorCode, error: answer.error)
}.chat
} catch is CancellationError {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import Testing
/// Bring Back Local (cmuxterm-hq#1874 slice 4) at the app boundary: a chat moved to a Cloud machine
/// with `cmux cloud move` comes back with `cmux cloud bring-back`, both sent as the CLI sends them
/// to the app's real action registry. This Mac and the machine are two real cmux-tui daemons, as
/// in ``CloudMoveTests``; `cmux link sync push|pull` and `cmux link agent move` are a stand-in
/// in ``CloudMoveTests``; `cmux link seed push, link sync pull` and `cmux link agent move` are a stand-in
/// `cmux` that records its arguments and answers as the case asks. The Rust suites cover those
/// CLIs against daemons (link_sync.rs, bring_back_journey.rs).
@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui"))
Expand Down Expand Up @@ -300,7 +300,7 @@ struct CloudBringBackTests {
let log: URL
let vmSocket: String
var macSocket = ""
/// The synced folder on the machine (`cmux link sync push`'s target).
/// The synced folder on the machine (`cmux link seed push`'s target).
var synced = ""
/// The moved chat's tab on the machine, as the app keys it, and its surface there.
var machineKey = ""
Expand Down Expand Up @@ -481,7 +481,7 @@ struct CloudBringBackTests {
trip.synced = try #require(trip.calls().first?.last)
}

/// A stand-in `cmux` (calls recorded unit-separated): `link sync push` makes the synced folder
/// A stand-in `cmux` (calls recorded unit-separated): `link seed push` makes the synced folder
/// and `link agent move` moves the chat to the machine, leaving the landed chat's record as the
/// machine daemon's `agent-transfer-finish` would (see ``CloudMoveTests``); then `link sync
/// pull` and the move back answer as `mode` says. `link access triage` lists one file ask of the
Expand All @@ -502,7 +502,7 @@ struct CloudBringBackTests {
for arg in "$@"; do line="$line$(printf '\\037')$arg"; done
printf '%s\\n' "${line#?}" >> '\(log.path)'
case "$1 $2 $3" in
"link sync push")
"link seed push")
for target in "$@"; do :; done
mkdir -p "$target"
echo '{"ok":true,"written":1,"unchanged":0,"deleted":0,"skipped":0,"bytes":4}' ;;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import Testing
/// chat tab, sent as the CLI sends it (`action.run`, `cli: true`, origin `cli`, a `tab:` target)
/// to the app's real action registry. This Mac is a real cmux-tui daemon; the Cloud machine is a
/// second real daemon reached the way the app reaches a machine's link socket
/// (`DaemonService.start(remote:)` on a ``CloudMachineSession``). `cmux link sync push` and
/// (`DaemonService.start(remote:)` on a ``CloudMachineSession``). `cmux link seed push` and
/// `cmux link agent move` are a stand-in `cmux` that records its arguments and answers their
/// JSON lines as the case asks; the Rust suites cover those CLIs against daemons (link_sync.rs,
/// link_agent_move.rs, cloud_move_journey.rs).
Expand All @@ -31,10 +31,10 @@ struct CloudMoveTests {
let moved = try await move.read()

let synced = move.workRoot.appendingPathComponent(moved.folderName).path
#expect(moved.calls.first == ["link", "sync", "push", "--socket", move.vmSocket, move.project.path, synced])
#expect(moved.calls.first == ["link", "seed", "push", "--socket", move.vmSocket, move.project.path, synced])
#expect(moved.calls.dropFirst().first == ["link", "agent", "move", "--from-socket", moved.macSocket, "--surface", String(moved.surface),
"--to-socket", move.vmSocket, "--cwd", synced, "--interrupt",
"--sync", "push", "--sync-socket", move.vmSocket, "--sync-local", move.project.path,
"--sync", "seed", "--sync-socket", move.vmSocket, "--sync-local", move.project.path,
"--sync-remote", synced, "--vm-kind", "none"],
"no link said this caller owns the machine: the pump says so, and the machine refuses")
#expect(moved.calls.count == 2)
Expand Down Expand Up @@ -197,7 +197,7 @@ struct CloudMoveTests {
while move.calls().count < 2, ContinuousClock.now < deadline { try? await Task.sleep(for: .milliseconds(50)) }
let after = await move.toolCards(waiting: false)?.first.flatMap { Self.text($0, "text") }
let again = try #require(move.calls().dropFirst().first, "Retry did not run the move again: \(move.calls()); card: \(after ?? "none")")
#expect(Array(again.prefix(5)) == ["link", "sync", "push", "--socket", move.vmSocket], "Retry ran somewhere else: \(again)")
#expect(Array(again.prefix(5)) == ["link", "seed", "push", "--socket", move.vmSocket], "Retry ran somewhere else: \(again)")
}
}

Expand Down Expand Up @@ -263,7 +263,7 @@ struct CloudMoveTests {
let deadline = ContinuousClock.now + .seconds(origin == .cli ? 30 : 10)
while move.calls().count < before + 2, ContinuousClock.now < deadline { try? await Task.sleep(for: .milliseconds(50)) }
let ran = move.calls().dropFirst(before).map { Array($0.prefix(3)) }
if origin == .cli { #expect(ran == [["link", "sync", "push"], ["link", "agent", "move"]], "\(ran)") }
if origin == .cli { #expect(ran == [["link", "seed", "push"], ["link", "agent", "move"]], "\(ran)") }
// The stop's own bookkeeping, after its last call.
try? await Task.sleep(for: .milliseconds(300))
}
Expand All @@ -280,7 +280,7 @@ struct CloudMoveTests {
let moved = try await move.read()
let calls = Array(moved.calls.dropFirst(before))
#expect(calls.first == ["link", "machine", "update", "--socket", move.vmSocket], "\(calls)")
#expect(calls.dropFirst().map { Array($0.prefix(3)) } == [["link", "sync", "push"], ["link", "agent", "move"]], "\(calls)")
#expect(calls.dropFirst().map { Array($0.prefix(3)) } == [["link", "seed", "push"], ["link", "agent", "move"]], "\(calls)")
#expect(moved.machineTab?.agentSession?.session == "chat-on-vm", "the chat did not move after the update")
#expect(!moved.macStillHasTab)
}
Expand Down Expand Up @@ -623,7 +623,7 @@ struct CloudMoveTests {
}

/// A stand-in `cmux`: records each call's arguments (unit-separated). Like the real sync,
/// `link sync push` creates the synced folder (not in `tabFailed`, so the machine's terminal
/// `link seed push` creates the synced folder (not in `tabFailed`, so the machine's terminal
/// can't start there; in `syncTimeout` it outlives the CLI timeout); `link agent move`
/// answers as `mode` says. A move that answers ok also leaves what the machine daemon's own
/// `agent-transfer-finish` would: the landed chat's record (`agent-transfer-landed.json` next
Expand All @@ -637,7 +637,7 @@ struct CloudMoveTests {
for arg in "$@"; do line="$line$(printf '\\037')$arg"; done
printf '%s\\n' "${line#?}" >> '\(log.path)'
case "$1 $2 $3" in
"link sync push")
"link seed push")
[ '\(mode)' = syncTimeout ] && exec sleep 30
if [ '\(mode)' = syncOutdated ]; then
echo '{"ok":false,"error":"the machine runs an older cmux-tui (0000old) without fs-v1; update the machine","error_code":"machine_outdated"}'
Expand Down
6 changes: 6 additions & 0 deletions cmux-tui/crates/cmux-tui/src/link/seed_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ pub(super) fn failure_code(error: &anyhow::Error) -> String {
SeedError::of(error).code
}

/// The error code and message of a failed [`push`].
pub(super) fn failure(error: &anyhow::Error) -> (String, String) {
let error = SeedError::of(error);
(error.code, error.message)
}

/// What a push did.
#[derive(Debug)]
pub(super) struct Pushed {
Expand Down
61 changes: 42 additions & 19 deletions cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@
//! to the machine the App just made for it, and makes it the workspace's
//! when the workspace has none yet and no other workspace has it.
//! - `copy --key K --account A --machine ID --socket PATH --local ROOT
//! [--subpath SUB] --work-root R...`: copies ROOT to a folder of the send's
//! own on the machine (`cmux link sync push`), trying each work root in
//! turn, unless this send's copy already finished there: a second push
//! would overwrite what the chat changed since. Answers the chat's folder
//! there.
//! [--subpath SUB] --work-root R...`: puts ROOT in a folder of the send's
//! own on the machine (`cmux link seed push`: seeded from its GitHub
//! remote, else copied), trying each work root in turn, unless this send's
//! copy already finished there: a second push would overwrite what the chat
//! changed since. Answers the chat's folder there, and how it got there:
//! `mode` (`seed` or `copy`), `fallback_code`, `fallback_why` and
//! `install_url` when a seed copied instead, and `skipped_files`. The push's
//! progress lines go to stderr.
//! - `ensure --workspace W --key K --repo OWNER/NAME [--ref REF] [--environment env_...]
//! [--platform URL]`, the cmux credential on stdin: the workspace's own
//! machine from the cmux platform (`POST /v1/workspaces/{W}/machine`), made
Expand Down Expand Up @@ -610,20 +613,33 @@ pub(super) fn import(ledger: &Path, entries: &str) -> Value {
}
}

/// One push of a folder to a machine: `(socket, local, remote)` to the push's
/// error code and message.
pub(super) type Push<'a> = dyn Fn(&Path, &Path, &str) -> Result<(), (String, String)> + 'a;
/// One push of a folder to a machine: `(socket, local, remote)` to what the
/// answer adds about it ([`pushed_fields`]), or the push's error code and
/// message.
pub(super) type Push<'a> = dyn Fn(&Path, &Path, &str) -> Result<Value, (String, String)> + 'a;

/// `cmux link sync push`, after the machine says it serves `fs-v1`.
fn push(socket: &Path, local: &Path, remote: &str) -> Result<(), (String, String)> {
if let Some(outdated) =
super::machine_gate::outdated(socket, &[cmux_tui_core::fs_ops::FS_CAPABILITY])
{
return Err((super::machine_gate::OUTDATED.to_string(), outdated.message()));
/// `cmux link seed push` (design/cloud.md, "Getting the project onto the
/// machine"): a git folder with a GitHub remote is seeded from it, anything
/// else copied (`cmux link sync push`), which refuses a machine without
/// `fs-v1` (`machine_outdated`) itself.
fn push(socket: &Path, local: &Path, remote: &str) -> Result<Value, (String, String)> {
super::seed_cli::push(socket, local, remote)
.map(|pushed| pushed_fields(&pushed.json()))
.map_err(|error| super::seed_cli::failure(&error))
}

/// What the copy's answer carries of the push's own line: how it went
/// (`mode`: seed or copy), why a seed copied instead (`fallback_code`,
/// `fallback_why`, and `install_url` when the machine needs the GitHub App),
/// and the files it left on this Mac, by name.
fn pushed_fields(line: &Value) -> Value {
let mut fields = serde_json::Map::new();
for key in ["mode", "fallback_code", "fallback_why", "install_url", "skipped_files"] {
if let Some(value) = line.get(key).filter(|value| !value.is_null()) {
fields.insert(key.to_string(), value.clone());
}
}
super::sync_cli::push(socket, local, remote)
.map(|_| ())
.map_err(|error| super::sync_cli::push_failure(&error))
Value::Object(fields)
}

/// Folders a chat's copy refuses as too broad: the disk's root and the
Expand Down Expand Up @@ -782,14 +798,21 @@ impl StartCopy {
for root in &self.work_roots {
let (folder, chat) = self.remote(root, &place);
match push(&self.socket, Path::new(&self.local), &folder) {
Ok(()) => {
Ok(fields) => {
let recorded = Ledger::update(ledger, |pins| {
pins.record_copy(&chat, &send, &self.machine, &place);
});
lease.release();
return match recorded {
Ok(()) => {
json!({"ok": true, "chat": chat, "folder": folder, "copied": true})
let mut answer =
json!({"ok": true, "chat": chat, "folder": folder, "copied": true});
if let (Some(answer), Value::Object(fields)) =
(answer.as_object_mut(), fields)
{
answer.extend(fields);
}
answer
}
Err(error) => refused("ledger_failed", format!("{error:#}")),
};
Expand Down
Loading
Loading