Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,13 @@ import Foundation
/// 2. `cloud.machine.create` goes once with one idempotency key and origin
/// `user` (the verified app; the server refuses a money op otherwise).
/// 3. The Mac relay calls it with the install token, so the backend answers
/// `approval.pending {request}`. The flow shows the native confirmation
/// if no person clicked in this flow yet; on the click it answers that
/// exact request as the person's session (`feed.answer`), then retries
/// with the SAME key until the op's answer arrives (bounded; the
/// approved op runs in the backend after the answer).
/// `approval.pending {request}`. The flow answers that exact request as
/// the person's session (`feed.answer`), then retries with the SAME key
/// until the op's answer arrives (bounded; the approved op runs in the
/// backend after the answer).
///
/// The flow never answers an approval without a native confirmation click
/// in this flow, and a declined confirmation sends nothing more.
/// The flow answers an approval only after a person's native click: their
/// own gesture, or step 1's confirmation (a no sends nothing more).
@MainActor
struct CloudMachineCreateFlow {
/// What the person confirms: the exact machine the create asks for.
Expand Down Expand Up @@ -78,9 +77,6 @@ struct CloudMachineCreateFlow {
var approve: @MainActor (_ request: String, _ op: String, _ params: JSONValue) async throws -> Void
/// Waits before the next same-key retry after the approval (cancellable).
var pause: @MainActor (_ attempt: Int) async throws -> Void
/// Waits between the same-key calls that watch, while the approval sheet is open, for the person
/// answering the hold elsewhere (cancellable); nil watches nothing and only the sheet answers.
var watchPause: (@MainActor (_ attempt: Int) async throws -> Void)?
var newKey: () -> String = { UUID().uuidString }
/// Same-key calls after the approval before ``Failure/stillPending``.
var retries = 8
Expand Down Expand Up @@ -108,26 +104,23 @@ struct CloudMachineCreateFlow {
throw Failure.upgradeRequired(need.reason)
}
let machine = Self.named(name, askedBy: askedBy)
let confirmed = !startedByPerson
if confirmed { guard await confirm(.agentRequest(machine)) else { throw Failure.declined } }
// A person's own gesture is the confirmation, so the backend's hold gets no second prompt.
if !startedByPerson { guard await confirm(.agentRequest(machine)) else { throw Failure.declined } }
var args: [String: JSONValue] = ["size": .object(["cpu": .number(Double(machine.cpu)), "memory_mb": .number(Double(machine.memoryMB)), "disk_mb": .number(Double(machine.diskMB))])]
if let name = machine.name { args["name"] = .string(name) }
if let environment { args["environment"] = .string(environment) }
let op = environment == nil ? "cloud.machine.create" : "cloud.environment.boot"
let sent = key ?? newKey()
let answer = try await approvalLoop(op, args: .object(args), key: sent, confirmed: confirmed,
let answer = try await approvalLoop(op, args: .object(args), key: key ?? newKey(), confirmed: true,
approved: key == nil ? nil : approved, onApproved: onApproved,
answering: key == nil ? nil : answering, onAnswering: onAnswering) { [self] request in
await confirmUnlessAnsweredElsewhere(.approval(request: request, machine), op: op, args: .object(args), key: sent, request: request)
}
answering: key == nil ? nil : answering, onAnswering: onAnswering) { _ in true }
return answer["machine"] ?? answer
}

/// Any other Cloud money or destructive op (`cloud.environment.build`, `.delete`) a person started
/// in this app: sent once with one key; when the backend holds it for approval, `confirm` asks the
/// person (a no sends nothing more), then the approval is answered and the same key retried.
func approvedCall(_ op: String, args: JSONValue, confirm: @MainActor () async -> Bool) async throws -> JSONValue {
try await approvalLoop(op, args: args, key: newKey(), confirmed: false) { _ in await confirm() ? .yes : .no }
try await approvalLoop(op, args: args, key: newKey(), confirmed: false) { _ in await confirm() }
}

/// The first call, then at most `retries` same-key calls after the approval: every pass
Expand All @@ -137,7 +130,7 @@ struct CloudMachineCreateFlow {
private func approvalLoop(_ op: String, args: JSONValue, key: String, confirmed: Bool, approved earlier: String? = nil,
onApproved: @MainActor (String) -> Void = { _ in },
answering: String? = nil, onAnswering: @MainActor (String) -> Void = { _ in },
ask: @MainActor (_ request: String) async -> Confirmation) async throws -> JSONValue {
ask: @MainActor (_ request: String) async -> Bool) async throws -> JSONValue {
var confirmed = confirmed
var approved: String?
var attempt = 0
Expand All @@ -155,15 +148,8 @@ struct CloudMachineCreateFlow {
continue
}
if !confirmed {
let answer = await ask(request)
guard answer != .no else { throw Failure.declined }
guard await ask(request) else { throw Failure.declined }
confirmed = true
if answer == .answeredElsewhere {
// Its item is closed, so it is not answered again; the same-key retry gets the op's answer.
approved = request
onApproved(request)
continue
}
}
onAnswering(request)
do {
Expand All @@ -172,6 +158,12 @@ struct CloudMachineCreateFlow {
where Self.alreadyAnswered(failure, request: request, answeredEarlier: request == answering) {
// An earlier attempt answered it and was cancelled before it could record that: the
// item is closed, so wait for the approved op like any answered request.
} catch let failure as CloudApprovalAnswer.Failure where Self.itemClosed(failure, request: request) {
// Answered elsewhere (the feed on a phone, the dashboard) before this Mac could: nothing
// was answered here, so it is not recorded; the bounded same-key retries read the op's answer.
approved = request
attempt = 1
continue
}
approved = request
onApproved(request)
Expand All @@ -182,33 +174,6 @@ struct CloudMachineCreateFlow {
throw Failure.stillPending(request: approved ?? "")
}

/// The approval sheet, closed when the person answers the hold elsewhere first (the feed on their
/// phone, the dashboard; G8 lets their own session answer it): the same-key call goes on at `watchPause`'s
/// pace, and an answer other than this request's pending ends the sheet as a yes, so the loop finds the
/// request ended (``alreadyAnswered``) and the next same-key call replays the op's answer.
private func confirmUnlessAnsweredElsewhere(_ prompt: Prompt, op: String, args: JSONValue, key: String, request: String) async -> Confirmation {
guard let watchPause else { return await confirm(prompt) ? .yes : .no }
let elsewhere = AnsweredElsewhere()
let yes = await CloudWait.firstAnswer(ask: { await confirm(prompt) }) { [run] in
elsewhere.seen = await CloudWait.untilAnswered(pause: watchPause) {
do { _ = try await run(op, args, key, .user) } catch let failure as CloudAppOpFailure where failure.code == CloudAppOpFailure.approvalPending {
return failure.approvalRequest == request ? nil : false
} catch { return isDefinitiveAnswer(error) }
return true
}
return elsewhere.seen
}
return elsewhere.seen ? .answeredElsewhere : yes ? .yes : .no
}

enum Confirmation { case no, yes, answeredElsewhere }
@MainActor private final class AnsweredElsewhere { var seen = false }

/// A denied or expired hold was answered too; anything else leaves the answer to the sheet.
private func isDefinitiveAnswer(_ error: any Error) -> Bool {
(error as? CloudAppOpFailure).map { failure in ["approval_denied", "approval.denied", "approval_expired", "approval.expired"].contains { failure.code.hasSuffix($0) } } ?? false
}

/// The legacy `/api/vm` create (the default link). A plan that cannot create (`gate`, or the
/// server's 402) shows one upgrade card and sends nothing. Otherwise a create no person started
/// goes out at once; only the opt-in `askFirst` ("ask before creating", default off) asks first,
Expand Down Expand Up @@ -241,6 +206,11 @@ struct CloudMachineCreateFlow {
}
}

/// The open feed item for `request` is gone (answered elsewhere, or another team's).
static func itemClosed(_ failure: CloudApprovalAnswer.Failure, request: String) -> Bool {
if case .mismatch(request, CloudApprovalAnswer.closedItem) = failure { true } else { false }
}

/// 1, 2, 4 … 8 s between the same-key retries after the approval.
static func backoff(_ attempt: Int) -> Duration { .seconds(min(8, 1 << min(max(attempt - 1, 0), 3))) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,6 @@ extension CloudService {
pause: { attempt in
// wakeup-allow: bounded same-key retry of a retryable approval.pending after the person approved (cx-t2rz)
try await ContinuousClock().sleep(for: CloudMachineCreateFlow.backoff(attempt))
},
watchPause: { attempt in
// wakeup-allow: same-key watch while the approval sheet is open; cancelled when the sheet closes
try await ContinuousClock().sleep(for: CloudMachineCreateFlow.backoff(attempt))
}
)
}
Expand Down
34 changes: 0 additions & 34 deletions Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Move/CloudWait.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,40 +39,6 @@ public struct CloudWait {
return connected() ? nil : failure() ?? deadline
}

/// The person's answer to `ask` (a sheet), unless `elsewhere` finds the same question answered somewhere
/// else first: then true, and `ask` is cancelled, which closes its sheet. Neither task outlives this call.
public static func firstAnswer(ask: @escaping @MainActor () async -> Bool, elsewhere: @escaping @MainActor () async -> Bool) async -> Bool {
let sheet = Task { @MainActor in await ask() }
let watch = Task { @MainActor in await elsewhere() }
// concurrency-allow: both children only await the two tasks above, which end on cancellation
return await withTaskCancellationHandler {
await withTaskGroup(of: Bool?.self) { group -> Bool in
group.addTask { await sheet.value }
group.addTask { await watch.value ? true : nil }
defer { sheet.cancel(); watch.cancel() }
for await answer in group {
if let answer { return answer }
}
return false
}
} onCancel: {
sheet.cancel()
watch.cancel()
}
}

/// Calls `poll` after each `pause`, at most `limit` times, until it decides: true answered, false give
/// up, nil ask again. False when the pause throws (cancelled), the task is cancelled or `limit` runs out.
public static func untilAnswered(limit: Int = 600, pause: @MainActor (_ attempt: Int) async throws -> Void,
poll: @MainActor () async -> Bool?) async -> Bool {
for attempt in 1...max(limit, 1) {
do { try await pause(attempt) } catch { return false }
if Task.isCancelled { return false }
if let decided = await poll() { return decided }
}
return false
}

private static func observe(_ condition: @escaping @MainActor @Sendable () -> Bool) async -> Bool {
for await ready in ObservationStream({ condition() }) where ready { return true }
return false
Expand Down

This file was deleted.

Loading
Loading