Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import CmuxNextActions
import CmuxNextCloud
import CmuxNextCloudUI
import CmuxNextDaemon

/// Automatic environment choice for a chat sent to Cloud (leo-workspace design/cloud-work-mode.md,
/// "Auto-sizing"): the machine a chat gets is chosen from the chat's folder (``CloudWorkPick``),
/// never by a prompt. Move to Cloud with no machine to reuse and New Cloud Machine asked from a
/// chat send the pick's `autosize` with the create, and the server answers the shape
/// (``CloudSizePick``, kept on the machine's session as ``CloudMachineSession/sizePick`` for the
/// hand-off).
///
/// Behind Labs preview features until the work-mode loop works end to end (the design's rule
/// for new surfaces): with them off nothing is read and every create is as before.
@MainActor
struct CloudChatPick {
let services: AppServices

/// The pick for the chat in `folder` (its tab `tab`: the title for the task kind, the agent for
/// the Codex broker). Nil while Labs preview features are off, for no absolute folder, and on
/// the Cloud app server's link, whose create takes no `autosize` yet (``CloudService/createMachine``).
func read(folder: String?, tab: TabModel?) async -> CloudWorkPick? {
guard services.settings?.snapshot.previewFeatures == true, services.cloud.configuration.linkSource == .legacy,
let folder, folder.hasPrefix("/") else { return nil }
return await CloudWorkPick.read(folder: folder, text: tab?.displayTitle, harness: tab?.agentSession?.harness, work: services.work)
}

/// The chat tab of this Mac an action was asked from (the composer's New Cloud Machine names
/// its tab); nil from anywhere else (the palette, the CLI, a chat on another machine).
static func chatTab(_ invocation: ActionInvocation, _ services: AppServices) -> TabModel? {
guard let target = invocation.target, target.kind == .tab, let (tab, _) = services.locateTab(target.id),
let record = tab.agentSession, services.machines.daemon(forTab: tab).isLocal,
!record.host.hasPrefix(MachineRegistry.registryHostPrefix) else { return nil }
return tab
}

/// A bare create sized for the chat; with no pick, the plan's default as before.
static func request(sizedFor pick: CloudWorkPick?) -> CloudMachineRequest {
var request = CloudMachineRequest()
request.autosize = pick?.autosize
return request
}

/// The setup's request, sized for the chat only when the machine gets the chat's own
/// repository: an empty VM or another repository keeps the plan's default size, as before.
static func request(_ plan: CloudWorkspaceSetupWizard.Plan, sizedFor pick: CloudWorkPick?) -> CloudMachineRequest {
var request = plan.request
if let pick, let chosen = plan.repository, let own = pick.repo,
chosen.owner.lowercased() == own.owner.lowercased(), chosen.name.lowercased() == own.name.lowercased() {
request.autosize = pick.autosize
}
return request
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import CmuxNextWakeups
final class CloudMachineSession {
let machineID: String
var machine: CloudMachine
/// Its create's auto-size pick, which a list read's ``machine`` lacks: a hand-off's `size`.
var sizePick: CloudSizePick?
let daemon: DaemonService
@ObservationIgnored let link: CloudMachineLink?
@ObservationIgnored let appLink: CloudLinkSession?
Expand Down Expand Up @@ -79,6 +81,7 @@ final class CloudMachineSession {
localIdentity: @escaping @MainActor () -> DaemonIdentity?, mainWork: MainWorkScheduler) {
machineID = machine.id
self.machine = machine
sizePick = machine.sizePick
self.link = link
self.appLink = appLink
self.localIdentity = localIdentity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ struct CloudMove {
guard let chat = try await chatFolder(tab: tab, workspace: workspace, session: session, invocation: invocation),
let folder = CloudMoveFolder(chat: chat) else { throw Failure(message: CloudMoveStrings.noFolder) }
guard !folder.isTooBroad else { throw Failure(message: CloudMoveStrings.homeFolder) }
let machine = try await self.machine(invocation)
let machine = try await self.machine(invocation) { await CloudChatPick(services: services).read(folder: chat, tab: tab) }
let name = machine.machine.title, again = CloudMoveRetries.Retry.move(machineID: machine.machineID)
// From here the chat may move to this machine: a quit or a crash before the move ends
// leaves Retry pinned to it, and the relaunched app finishes the move there.
Expand Down Expand Up @@ -255,8 +255,8 @@ struct CloudMove {
return (chats + tabs).lazy.compactMap(\.cwd).first { $0.hasPrefix("/") }
}

/// The machine the chat moves to, connected.
private func machine(_ invocation: ActionInvocation) async throws -> CloudMachineSession {
/// The machine the chat moves to, connected; a new one is sized for the chat (`pick`).
private func machine(_ invocation: ActionInvocation, pick: () async -> CloudWorkPick?) async throws -> CloudMachineSession {
let machines = services.machines
let named = invocation["machine"]?.targetValue?.id ?? invocation["machine"]?.stringValue
if let named {
Expand All @@ -267,7 +267,8 @@ struct CloudMove {
if let live = machines.cloud.first(where: { $0.daemon.connection != nil }) ?? machines.cloud.first {
return try await connected(live, invocation)
}
let created = try await services.cloud.createMachine(name: nil, startedByPerson: invocation.origin == .user, askedBy: invocation.callerAgentSession)
let created = try await services.cloud.createMachine(name: nil, startedByPerson: invocation.origin == .user, askedBy: invocation.callerAgentSession,
request: CloudChatPick.request(sizedFor: await pick()))
return try await connected(created, invocation)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ final class CloudService {
// The write waited: the account is checked again before the machine joins the list.
try proceed()
}
if let existing = machines.session(machine.id) { return existing }
if let existing = machines.session(machine.id) { existing.sizePick = machine.sizePick ?? existing.sizePick; return existing }
guard let session = addSession(machine) else { throw ActionFailure(message: CloudStrings.noClient) }
// A list in flight was asked before this machine existed.
listGeneration += 1
Expand All @@ -606,7 +606,7 @@ final class CloudService {
onSent: @escaping @MainActor @Sendable () async -> Void) async throws -> CloudMachine {
// `api` is pinned to the first send's team: the server scopes a key per team, so a team
// switched in the app meanwhile must not make the same key a new machine in the other team.
let send = { try await api.createMachine(displayName: name, memoryMb: request.memoryMb, idlePauseMinutes: idlePause,
let send = { try await api.createMachine(displayName: name, memoryMb: request.memoryMb, autosize: request.autosize, idlePauseMinutes: idlePause,
idempotencyKey: request.idempotencyKey, onSent: onSent) }
for _ in 0..<inProgressRetries {
do {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ extension CloudHandlers {
let request = try newMachineRequest(invocation)
let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil
if request.isDefault, origin(of: invocation) == .user, window != nil {
beginWorkspaceSetup(context, window: window, askedBy: invocation.callerAgentSession)
beginWorkspaceSetup(context, window: window, askedBy: invocation.callerAgentSession, chat: CloudChatPick.chatTab(invocation, context.services))
return
}
let flow = creationFlow(context, startedByPerson: invocation.origin == .user, askedBy: invocation.callerAgentSession)
Expand Down Expand Up @@ -58,7 +58,19 @@ extension CloudHandlers {
}
}

static func beginWorkspaceSetup(_ context: AppActionContext, window: WindowState?, askedBy: String?) {
/// The asking chat's folder: its tab's cwd, else the folder its local session reports (a chat
/// tab may have no cwd cached yet), as Move to Cloud reads it.
static func chatFolder(_ chat: TabModel?, _ services: AppServices) async -> String? {
guard let chat else { return nil }
if let cwd = chat.cwd, cwd.hasPrefix("/") { return cwd }
let tabs = services.agentTabs
guard let session = tabs.sessions[tabs.resolve(chat.id)] ?? chat.agentSession?.session else { return nil }
let host = tabs.localSessionHost ?? tabs.host
guard let folder = try? await host.sessionFolder(sessionId: session), folder.isLocal, folder.cwd.hasPrefix("/") else { return nil }
return folder.cwd
}

static func beginWorkspaceSetup(_ context: AppActionContext, window: WindowState?, askedBy: String?, chat: TabModel? = nil) {
runTracked("new Cloud workspace", context) {
let cloud = context.services.cloud, services = context.services
let userID = cloud.auth.user?.id, teamID = cloud.auth.teamID
Expand All @@ -68,15 +80,15 @@ extension CloudHandlers {
let scope: CmuxDialogScope = services.windows.active?.window.map(CmuxDialogScope.window) ?? .app
return await CmuxDialogCenter.shared.present(spec, in: scope)
}
let wizard = CloudWorkspaceSetupWizard()
let wizard = CloudWorkspaceSetupWizard(), pick = await CloudChatPick(services: services).read(folder: await chatFolder(chat, services), tab: chat)
// Environments live in the cmux-next backend's CloudDO: only its Cloud path lists them.
let supportsEnvironments = cloud.configuration.linkSource == .appServer
let environments = supportsEnvironments ? await cloud.appServerEnvironmentOptions() : []
guard let plan = try await wizard.choosePlan(availability: availability,
scopeIsCurrent: { cloud.auth.user?.id == userID && cloud.auth.teamID == teamID },
supportsRepository: cloud.configuration.linkSource == .legacy,
supportsRepository: cloud.configuration.linkSource == .legacy, suggested: pick?.repo,
environments: environments, supportsEnvironments: supportsEnvironments, present: present) else { return }
var request = plan.request
var request = CloudChatPick.request(plan, sizedFor: pick)
if let new = plan.newEnvironment {
// Saved first, then booted: the first machine starts cold and runs the setup itself.
guard cloud.auth.user?.id == userID, cloud.auth.teamID == teamID else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,13 @@ public struct CloudAPIClient: Sendable {
/// team resolved), so a caller can tell its request stage from the
/// server's create.
/// `idlePauseMinutes`: the machine's idle auto-pause from its create (0 is Off, a persistent
/// machine); a server that does not store it answers without it (or with its default).
public func createMachine(displayName: String?, memoryMb: Int? = nil, idlePauseMinutes: Int? = nil,
idempotencyKey: String = UUID().uuidString,
/// machine); a server that does not store it answers without it (or with its default). `autosize`: ``CloudAutosizeRequest``.
public func createMachine(displayName: String?, memoryMb: Int? = nil, autosize: CloudAutosizeRequest? = nil,
idlePauseMinutes: Int? = nil, idempotencyKey: String = UUID().uuidString,
onSent: (@Sendable () async -> Void)? = nil) async throws -> CloudMachine {
var body: [String: any Sendable] = [:]
if let displayName, !displayName.isEmpty { body["displayName"] = displayName }
if let memoryMb { body["memoryMb"] = memoryMb }
if let memoryMb { body["memoryMb"] = memoryMb } else if let autosize { body["autosize"] = autosize.body }
if let idlePauseMinutes {
let value: any Sendable = idlePauseMinutes == 0 ? "off" : idlePauseMinutes
body["idlePauseMinutes"] = value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ public struct CloudMachine: Sendable, Hashable, Identifiable, Decodable {
/// When the machine last started running (`runningSince`, epoch ms).
/// Nil while it is paused, so a paused machine shows no running cost.
public var runningSince: Date?
/// The auto-size pick a create with `autosize` answered (``CloudSizePick``); nil on a list
/// record, on a create that sent its own size, and on an older server.
public var sizePick: CloudSizePick?

/// The declared capabilities, or the conservative empty set when the
/// record declared none. Gate actions with ``allows(_:)``, not this.
Expand Down Expand Up @@ -235,7 +238,7 @@ public struct CloudMachine: Sendable, Hashable, Identifiable, Decodable {
enum CodingKeys: String, CodingKey {
case id, provider, status, displayName, slug, kind, image, imageVersion, createdAt, address, size, capabilities
case pauseReason, idlePauseMinutes, idlePause
case hourlyRateUsd, runningSince
case hourlyRateUsd, runningSince, sizePick
}

public init(id: String, provider: String = "freestyle", status: Status = .running, displayName: String? = nil,
Expand Down Expand Up @@ -277,6 +280,7 @@ public struct CloudMachine: Sendable, Hashable, Identifiable, Decodable {
// Display facts: a malformed value drops the cost line, never the machine.
hourlyRateUsd = (try? c.decodeIfPresent(Double.self, forKey: .hourlyRateUsd)) ?? nil
runningSince = ((try? c.decodeIfPresent(Double.self, forKey: .runningSince)) ?? nil).map { Date(timeIntervalSince1970: $0 / 1000) }
sizePick = (try? c.decodeIfPresent(CloudSizePick.self, forKey: .sizePick)) ?? nil
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
public import Foundation

/// What a chat's Cloud machine is sized for: `POST /api/vm`'s `autosize.taskKind`
/// (web/services/vms/autosize.ts). A chat is light work whatever the repo; a build or a test
/// gets the fleet's measured grant for the repo's toolchain.
public enum CloudTaskKind: String, Sendable, Equatable, CaseIterable {
case chat, build, test

/// The kind the chat's own words make obvious, else ``chat``. The rule is kept simple on
/// purpose, and it is the whole rule: a word that names testing (``testWords``) makes it
/// ``test``; else a word that names building (``buildWords``) makes it ``build``; anything
/// else is ``chat``. Whole words only, so "latest" or "rebuilding trust" name no task, and
/// testing wins over building because a test run builds first.
///
/// - Parameter text: The chat's prompt or title; nil is a chat.
public static func inferred(from text: String?) -> CloudTaskKind {
guard let text, !text.isEmpty else { return .chat }
let words = Set(text.lowercased().split { !($0.isLetter || $0.isNumber) }.map(String.init))
if !words.isDisjoint(with: testWords) { return .test }
if !words.isDisjoint(with: buildWords) { return .build }
return .chat
}

/// The words that make a chat a test run.
public static let testWords: Set<String> = ["test", "tests", "testing", "e2e", "xctest", "pytest", "vitest", "jest"]
/// The words that make a chat a build.
public static let buildWords: Set<String> = ["build", "builds", "building", "rebuild", "compile", "compiles", "compiling"]
}

/// `POST /api/vm`'s `autosize`: what the server's picker needs to choose the machine's shape
/// with no prompt (the repo's identity, size and root files, and the task kind).
public struct CloudAutosizeRequest: Equatable, Sendable {
public var taskKind: CloudTaskKind
/// `host/owner/name`, lower-cased: the key the server files measured runs under. Nil outside
/// git, or with no remote it can name.
public var repoKey: String?
/// The repository's git objects in MiB (`git count-objects`); nil outside git.
public var repoSizeMb: Int?
/// The names at the working copy's top (`Package.swift`, `Cargo.toml`, `package.json`, …),
/// which tell the server the toolchain.
public var rootFiles: [String]

public init(taskKind: CloudTaskKind = .chat, repoKey: String? = nil, repoSizeMb: Int? = nil, rootFiles: [String] = []) {
self.taskKind = taskKind
self.repoKey = repoKey
self.repoSizeMb = repoSizeMb
self.rootFiles = rootFiles
}

/// The server's limits (autosize.ts `parseAutosizeRepo`): at most 500 names of at most 255
/// characters, a key of at most 200 characters.
public static let maxRootFiles = 500
public static let maxNameLength = 255
public static let maxKeyLength = 200

/// The JSON object the create sends.
public var body: [String: any Sendable] {
var repo: [String: any Sendable] = [:]
if let repoKey { repo["key"] = repoKey }
if let repoSizeMb { repo["sizeMb"] = repoSizeMb }
if !rootFiles.isEmpty { repo["rootFiles"] = rootFiles }
var body: [String: any Sendable] = ["taskKind": taskKind.rawValue]
if !repo.isEmpty { body["repo"] = repo }
return body
}
}

/// The create answer's `sizePick` (`POST /api/vm` with `autosize`): the shape the server chose
/// and one line on why, e.g. "4 vCPU · 16 GB, picked from 3 earlier runs". Its fields are the
/// hand-off's `size` (`POST /v1/handoffs {sessionId, machine, size}`), as ``handoffSize``.
public struct CloudSizePick: Equatable, Hashable, Sendable, Codable {
public var vcpus: Int
public var memoryGb: Double
/// The ladder row's name (`sm`, `md`, `lg`, …).
public var pick: String
/// The one line the chat shows.
public var reason: String

public init(vcpus: Int, memoryGb: Double, pick: String, reason: String) {
self.vcpus = vcpus
self.memoryGb = memoryGb
self.pick = pick
self.reason = reason
}

/// `POST /v1/handoffs`'s `size` object.
public var handoffSize: [String: any Sendable] { ["vcpus": vcpus, "memoryGb": memoryGb, "pick": pick, "reason": reason] }
}
Loading
Loading