mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 01:48:20 -10:00
feat(cli): cloud Work speaks the neutral platform dialect; launch defaults to the quoted seconds
Lands the previously uncommitted crates/cli/src/cloud.rs, cloud/work.rs and
cloud/tests.rs edits (repo-free taskContext via --context-file/--file,
served-offer variable seconds, --recovery-from, account file custody) and
moves the cloud Work flow off vendor names:
- reads GET /api/sandbox/work-offer (not the vendor-named alias), expects sku
task-small and funding trial_credit/compute_cu, and sends sandboxSku
task-small; the quote no longer asserts a provider adapter;
- hint codes are the ones the server emits (work_vm_trial_unavailable,
work_vm_provider_unavailable), not the dead boat_work_* ones;
- customer-visible text drops the vendor ("small cloud computer in the EU",
"Computer usage:", "Computer: <status>", consent text) and the computer
trial flag is --computer-trial with --boat-trial kept as a hidden alias;
- work-launch without --seconds now loads the offer and uses the same default
as work-quote instead of a fixed 300 seconds, so a balance-funded launch can
no longer disagree with its quote;
- run usage is read from computeUsage first, then the current boatUsage field.
Wire-level names the server still emits stay: error codes boat_task_*, the
boatUsage field, and the computers API provider/boatEuComputeOptIn fields.
Tests migrated to the neutral fixtures; the quote-adapter tamper case was
removed with the check; LAUNCH_ARGV now passes --seconds 300.
Evidence (L1 local only): cargo test -p codewhale-cli cloud: 123 passed, 0
failed. Built on a tree that also holds another agent's uncommitted crate-split
and grants.rs edits (not staged here); tui needed a temporary
#[allow(dead_code)] on GrantStore::degraded_reason, left uncommitted in
grants.rs, to compile under -D warnings. No clippy run, no live call.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
c1b4cd5846
commit
d4a61574bd
12 files changed
+513
-129
No files matched your search
+135
-32
@@ -239,7 +239,7 @@ enum CloudAgentsCommand {
|
||||
#[arg(long, default_value_t = 0)]
|
||||
since_seq: u64,
|
||||
},
|
||||
/// Give a named Agent durable repository Work. This records a request; it does not start compute.
|
||||
/// Give a named Agent durable Work. This records a request; it does not start compute.
|
||||
///
|
||||
/// The Agent reads the message first: a plain instruction becomes Work, a
|
||||
/// command such as "stop" is applied to its active Work, a correction
|
||||
@@ -254,6 +254,13 @@ enum CloudAgentsCommand {
|
||||
/// Stable message ID for safe retry after an uncertain response.
|
||||
#[arg(long)]
|
||||
message_id: String,
|
||||
#[arg(long, help = "UTF-8 source context file, at most 64000 UTF-16 units")]
|
||||
context_file: Option<PathBuf>,
|
||||
#[arg(
|
||||
long = "file",
|
||||
help = "Saved account file reference FILE_ID@VERSION; repeat for up to ten files"
|
||||
)]
|
||||
file_refs: Vec<String>,
|
||||
},
|
||||
/// Read the account-owned status of a Work request.
|
||||
WorkStatus { id: String },
|
||||
@@ -280,7 +287,7 @@ enum CloudAgentsCommand {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Quote bounded Boat trial computer time for queued Work. Nothing starts.
|
||||
/// Quote offered computer time for Work. Nothing starts.
|
||||
///
|
||||
/// Prints the funding, EU placement and time disclosure plus a short-lived
|
||||
/// confirmation for `work-launch`. Reuse one --operation-key for the quote
|
||||
@@ -290,12 +297,22 @@ enum CloudAgentsCommand {
|
||||
/// Stable launch ID, reused by `work-launch` so a retry cannot start two computers.
|
||||
#[arg(long)]
|
||||
operation_key: String,
|
||||
#[arg(
|
||||
long,
|
||||
help = "Whole seconds within the served offer; defaults to its maximum"
|
||||
)]
|
||||
seconds: Option<u64>,
|
||||
#[arg(
|
||||
long,
|
||||
help = "Failed workspace ID to recover on a fresh computer with the same Work ID"
|
||||
)]
|
||||
recovery_from: Option<String>,
|
||||
},
|
||||
/// Start quoted Boat trial Work on EU compute (five minutes at most, $0 Codewhale charge).
|
||||
/// Start quoted Work on EU compute using the reviewed computer balance or trial.
|
||||
///
|
||||
/// Requires the confirmation printed by `work-quote` and --confirm-eu-compute,
|
||||
/// which agrees that repository code and Work files run on Boat's EU
|
||||
/// compute. Re-running with the same --operation-key and --confirmation is
|
||||
/// which agrees that repository code and Work files run on a cloud
|
||||
/// computer in the EU. Re-running with the same --operation-key and --confirmation is
|
||||
/// safe: it replays the launch instead of starting another computer.
|
||||
WorkLaunch {
|
||||
id: String,
|
||||
@@ -307,6 +324,13 @@ enum CloudAgentsCommand {
|
||||
/// Agree that repository code and Work files run on EU compute.
|
||||
#[arg(long)]
|
||||
confirm_eu_compute: bool,
|
||||
#[arg(
|
||||
long,
|
||||
help = "Exact whole seconds printed by work-quote; legacy trial default is 300"
|
||||
)]
|
||||
seconds: Option<u64>,
|
||||
#[arg(long, help = "Exact failed workspace ID used for the recovery quote")]
|
||||
recovery_from: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -327,11 +351,11 @@ enum CloudComputersCommand {
|
||||
/// Save a Computer identity; compute is allocated only when it starts.
|
||||
Create {
|
||||
name: String,
|
||||
/// Try Boat compute in the EU for this Computer.
|
||||
#[arg(long, requires = "eu_compute_opt_in")]
|
||||
boat_trial: bool,
|
||||
/// Confirm that Boat trial code and files run on EU compute.
|
||||
#[arg(long, requires = "boat_trial")]
|
||||
/// Try cloud compute in the EU for this Computer.
|
||||
#[arg(long, alias = "boat-trial", requires = "eu_compute_opt_in")]
|
||||
computer_trial: bool,
|
||||
/// Confirm that trial code and files run on EU compute.
|
||||
#[arg(long, requires = "computer_trial")]
|
||||
eu_compute_opt_in: bool,
|
||||
},
|
||||
/// Show one Computer; --json includes allowance and meter data.
|
||||
@@ -340,7 +364,7 @@ enum CloudComputersCommand {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Read Boat trial usage receipts for one Computer.
|
||||
/// Read trial usage receipts for one Computer.
|
||||
Usage {
|
||||
id: String,
|
||||
/// Print the full metering response.
|
||||
@@ -628,6 +652,22 @@ struct AccountAgentWork {
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
objective: String,
|
||||
#[serde(default)]
|
||||
task_context_digest: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WorkTaskContext {
|
||||
text: String,
|
||||
#[serde(rename = "fileRefs")]
|
||||
file_refs: Vec<WorkFileRef>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WorkFileRef {
|
||||
#[serde(rename = "fileId")]
|
||||
file_id: String,
|
||||
version: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1248,20 +1288,22 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
|
||||
agent_id: &str,
|
||||
objective: &str,
|
||||
message_id: &str,
|
||||
task_context: Option<&WorkTaskContext>,
|
||||
) -> Result<AgentWorkMessageResponse> {
|
||||
let agent_id = validate_resource_id(agent_id, "Agent")?;
|
||||
let objective = objective.trim();
|
||||
if objective.is_empty() || objective.chars().count() > 32_000 {
|
||||
if objective.is_empty() || objective.encode_utf16().count() > 32_000 {
|
||||
bail!("Work objective must contain 1-32000 characters");
|
||||
}
|
||||
let message_id = validate_operation_key(message_id)?;
|
||||
let mut body = serde_json::json!({ "messageId": message_id, "text": objective });
|
||||
if let Some(context) = task_context {
|
||||
body["taskContext"] = serde_json::to_value(context)?;
|
||||
}
|
||||
let response = self.execute_authenticated(
|
||||
HttpMethod::Post,
|
||||
&format!("/api/agents/{agent_id}/messages"),
|
||||
Some(json_body(&serde_json::json!({
|
||||
"messageId": message_id,
|
||||
"text": objective,
|
||||
}))?),
|
||||
Some(json_body(&body)?),
|
||||
)?;
|
||||
if ![200, 201, 202].contains(&response.status) {
|
||||
return Err(response_error(&response));
|
||||
@@ -1304,11 +1346,11 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
|
||||
fn create_computer(
|
||||
&self,
|
||||
name: &str,
|
||||
boat_trial: bool,
|
||||
computer_trial: bool,
|
||||
eu_compute_opt_in: bool,
|
||||
) -> Result<AccountComputer> {
|
||||
if boat_trial != eu_compute_opt_in {
|
||||
bail!("Boat trial requires both --boat-trial and --eu-compute-opt-in");
|
||||
if computer_trial != eu_compute_opt_in {
|
||||
bail!("A computer trial requires both --computer-trial and --eu-compute-opt-in");
|
||||
}
|
||||
let name = validate_computer_name(name)?;
|
||||
let response = self.execute_authenticated(
|
||||
@@ -1316,8 +1358,8 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
|
||||
"/api/computers",
|
||||
Some(json_body(&ComputerCreateRequest {
|
||||
name: &name,
|
||||
provider: boat_trial.then_some("boat"),
|
||||
boat_eu_compute_opt_in: boat_trial.then_some(true),
|
||||
provider: computer_trial.then_some("boat"),
|
||||
boat_eu_compute_opt_in: computer_trial.then_some(true),
|
||||
})?),
|
||||
)?;
|
||||
let result: ComputerResponse = expect_json(response, &[200, 201])?;
|
||||
@@ -2331,17 +2373,57 @@ fn run_agents<T: CloudTransport, W: Write>(
|
||||
agent,
|
||||
objective,
|
||||
message_id,
|
||||
context_file,
|
||||
file_refs,
|
||||
} => {
|
||||
let objective = objective.trim();
|
||||
if objective.is_empty() || objective.encode_utf16().count() > 32_000 {
|
||||
bail!("Work objective must contain 1-32000 UTF-16 units");
|
||||
}
|
||||
validate_operation_key(&message_id)?;
|
||||
let mut context = work::task_context(context_file.as_deref(), &file_refs)?;
|
||||
if objective.encode_utf16().count() > 12_000 {
|
||||
let context = context.get_or_insert_with(|| WorkTaskContext {
|
||||
text: String::new(),
|
||||
file_refs: Vec::new(),
|
||||
});
|
||||
context.text = if context.text.is_empty() {
|
||||
objective.to_string()
|
||||
} else {
|
||||
format!("{objective}\n\n{}", context.text)
|
||||
};
|
||||
if context.text.encode_utf16().count() > 64_000 {
|
||||
bail!("Task source context exceeds 64000 UTF-16 units");
|
||||
}
|
||||
}
|
||||
let listing: AgentListResponse = serde_json::from_value(client.agents()?)
|
||||
.context("The Codewhale service returned an invalid Agent list")?;
|
||||
let selected = resolve_account_agent(&listing.agents, &agent)?;
|
||||
let mut selected = resolve_account_agent(&listing.agents, &agent)?.clone();
|
||||
if selected.project_id.is_empty() {
|
||||
bail!(
|
||||
"Agent {} needs a repository Project before it can do Work",
|
||||
printable(&selected.name)
|
||||
);
|
||||
let response: AgentResponse = expect_json(
|
||||
client.execute_authenticated(
|
||||
HttpMethod::Post,
|
||||
"/api/work-box",
|
||||
Some(json_body(&serde_json::json!({}))?),
|
||||
)?,
|
||||
&[200],
|
||||
)?;
|
||||
if response.agent.id != selected.id || response.agent.project_id.is_empty() {
|
||||
bail!(
|
||||
"This Agent cannot use the account's repository-free task context. Use Agent {}",
|
||||
printable(&response.agent.name)
|
||||
);
|
||||
}
|
||||
selected = response.agent;
|
||||
}
|
||||
work::assign(client, out, &selected, &objective, &message_id)
|
||||
work::assign(
|
||||
client,
|
||||
out,
|
||||
&selected,
|
||||
objective,
|
||||
&message_id,
|
||||
context.as_ref(),
|
||||
)
|
||||
}
|
||||
CloudAgentsCommand::WorkStatus { id } => {
|
||||
let result = client.agent_work_status(&id)?;
|
||||
@@ -2367,20 +2449,39 @@ fn run_agents<T: CloudTransport, W: Write>(
|
||||
if let Some(title) = run.get("title").and_then(|value| value.as_str()) {
|
||||
writeln!(out, "Objective: {}", printable(title))?;
|
||||
}
|
||||
if let Some(workspace) = run
|
||||
.get("workspaceId")
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|id| !id.is_empty())
|
||||
{
|
||||
writeln!(out, "Workspace ID: {}", printable(workspace))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
CloudAgentsCommand::WorkCancel { id, queue, reason } => {
|
||||
work::cancel(client, out, &id, queue, reason.as_deref())
|
||||
}
|
||||
CloudAgentsCommand::WorkResult { id, json } => work::result(client, out, &id, json),
|
||||
CloudAgentsCommand::WorkQuote { id, operation_key } => {
|
||||
work::quote(client, out, &id, &operation_key)
|
||||
}
|
||||
CloudAgentsCommand::WorkQuote {
|
||||
id,
|
||||
operation_key,
|
||||
seconds,
|
||||
recovery_from,
|
||||
} => work::quote(
|
||||
client,
|
||||
out,
|
||||
&id,
|
||||
&operation_key,
|
||||
seconds,
|
||||
recovery_from.as_deref(),
|
||||
),
|
||||
CloudAgentsCommand::WorkLaunch {
|
||||
id,
|
||||
operation_key,
|
||||
confirmation,
|
||||
confirm_eu_compute,
|
||||
seconds,
|
||||
recovery_from,
|
||||
} => {
|
||||
let confirmation = if confirmation == "-" && confirm_eu_compute {
|
||||
if io::stdin().is_terminal() {
|
||||
@@ -2399,6 +2500,8 @@ fn run_agents<T: CloudTransport, W: Write>(
|
||||
&operation_key,
|
||||
&confirmation,
|
||||
confirm_eu_compute,
|
||||
seconds,
|
||||
recovery_from.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2454,10 +2557,10 @@ fn run_computers<T: CloudTransport, W: Write>(
|
||||
}
|
||||
CloudComputersCommand::Create {
|
||||
name,
|
||||
boat_trial,
|
||||
computer_trial,
|
||||
eu_compute_opt_in,
|
||||
} => {
|
||||
let computer = client.create_computer(&name, boat_trial, eu_compute_opt_in)?;
|
||||
let computer = client.create_computer(&name, computer_trial, eu_compute_opt_in)?;
|
||||
write_computer(out, &computer)?;
|
||||
writeln!(
|
||||
out,
|
||||
|
||||
@@ -241,18 +241,18 @@ fn parses_cloud_command_matrix_and_rejects_inline_keys() {
|
||||
"computers",
|
||||
"create",
|
||||
"Trial",
|
||||
"--boat-trial",
|
||||
"--computer-trial",
|
||||
"--eu-compute-opt-in"
|
||||
]),
|
||||
CloudCommand::Computers(CloudComputersArgs {
|
||||
command: CloudComputersCommand::Create {
|
||||
boat_trial: true,
|
||||
computer_trial: true,
|
||||
eu_compute_opt_in: true,
|
||||
..
|
||||
}
|
||||
})
|
||||
));
|
||||
for lone_flag in ["--boat-trial", "--eu-compute-opt-in"] {
|
||||
for lone_flag in ["--computer-trial", "--eu-compute-opt-in"] {
|
||||
assert!(
|
||||
Cli::try_parse_from([
|
||||
"codewhale",
|
||||
@@ -2311,7 +2311,7 @@ fn account_computers_boat_trial_and_usage_send_explicit_consent_and_read_receipt
|
||||
"computers",
|
||||
"create",
|
||||
"Trial",
|
||||
"--boat-trial",
|
||||
"--computer-trial",
|
||||
"--eu-compute-opt-in",
|
||||
],
|
||||
vec!["codewhale", "account", "computers", "usage", ID, "--json"],
|
||||
@@ -2908,6 +2908,8 @@ fn account_agent_work_records_one_idempotent_request_without_allocating_compute(
|
||||
agent: "Whale".into(),
|
||||
objective: "Fix the build".into(),
|
||||
message_id: "work-request-1".into(),
|
||||
context_file: None,
|
||||
file_refs: Vec::new(),
|
||||
},
|
||||
&client,
|
||||
&machine::MachineKeyEnv::default(),
|
||||
@@ -3409,12 +3411,20 @@ fn projects_step() -> Scripted {
|
||||
)
|
||||
}
|
||||
|
||||
fn boat_quote() -> serde_json::Value {
|
||||
fn work_offer() -> serde_json::Value {
|
||||
json!({
|
||||
"mode": "trial", "sku": "task-small", "region": "eu", "minSeconds": 60,
|
||||
"maxSeconds": 300, "model": { "provider": "deepseek", "model": "deepseek-flash" },
|
||||
"funding": "trial_credit", "computeRemainingSeconds": null, "emptyWorkspace": false
|
||||
})
|
||||
}
|
||||
|
||||
fn work_quote() -> serde_json::Value {
|
||||
json!({
|
||||
"runner": {},
|
||||
"quote": { "sku": "boat-small", "adapter": "boat", "target": "eu", "pricingStatus": "provider_trial" },
|
||||
"quote": { "sku": "task-small", "target": "eu", "pricingStatus": "provider_trial" },
|
||||
"disclosure": {
|
||||
"funding": "provider_trial", "customerCreditsChargedUsd": 0,
|
||||
"funding": "trial_credit", "customerCreditsChargedUsd": 0,
|
||||
"providerCostEstimateUsd": 0.0207, "sandboxTargetRegion": "eu",
|
||||
"euPlacementConsentRequired": true,
|
||||
"modelInference": { "billing": "byok_external" },
|
||||
@@ -3424,8 +3434,8 @@ fn boat_quote() -> serde_json::Value {
|
||||
"token": "tok.abc-123_DEF", "expiresAt": "2026-09-30T12:00:00.000Z", "workRunId": WORK_ID
|
||||
},
|
||||
"confirmCopy": {
|
||||
"title": "Run this five-minute Boat trial Work?",
|
||||
"body": "Boat trial credit covers its computer time.\u{1b}[31m",
|
||||
"title": "Run this five-minute trial Work?",
|
||||
"body": "Trial credit covers its computer time.\u{1b}[31m",
|
||||
"confirmLabel": "Start trial Work"
|
||||
}
|
||||
})
|
||||
@@ -3437,7 +3447,7 @@ fn cloud_session() -> serde_json::Value {
|
||||
"id": format!("session_{WORK_ID}"),
|
||||
"run": { "id": WORK_ID, "state": "planning" },
|
||||
"attempt": { "status": "accepted" },
|
||||
"sandbox": { "provider": "boat", "providerId": "bx_abcd1234", "status": "running" },
|
||||
"sandbox": { "status": "running" },
|
||||
"sandboxTargetRegion": "eu",
|
||||
"quote": { "customerCreditsChargedUsd": 0 },
|
||||
"initialTurn": { "status": "queued" }
|
||||
@@ -3453,7 +3463,7 @@ const QUOTE_ARGV: [&str; 7] = [
|
||||
"--operation-key",
|
||||
"launch-1",
|
||||
];
|
||||
const LAUNCH_ARGV: [&str; 10] = [
|
||||
const LAUNCH_ARGV: [&str; 12] = [
|
||||
"codewhale",
|
||||
"account",
|
||||
"agents",
|
||||
@@ -3463,6 +3473,8 @@ const LAUNCH_ARGV: [&str; 10] = [
|
||||
"launch-1",
|
||||
"--confirmation",
|
||||
"tok.abc-123_DEF",
|
||||
"--seconds",
|
||||
"300",
|
||||
"--confirm-eu-compute",
|
||||
];
|
||||
|
||||
@@ -3984,7 +3996,7 @@ fn work_result_summarises_evidence_pr_route_and_boat_usage() {
|
||||
"Draft PR: https://github.com/octo-org/app/pull/42",
|
||||
"Blockers: checks_failed",
|
||||
"Model route: deepseek/deepseek-flash",
|
||||
"Boat usage:",
|
||||
"Computer usage:",
|
||||
"Provider seconds: 212",
|
||||
"Provider list price: $0.0011",
|
||||
"Codewhale credits charged: $0",
|
||||
@@ -4057,7 +4069,7 @@ fn work_result_never_shows_a_link_that_is_not_a_github_pull_request_and_survives
|
||||
assert!(output.contains("Artifacts: none"));
|
||||
assert!(output.contains("Draft PR: none"));
|
||||
assert!(output.contains("Model route: not reported"));
|
||||
assert!(!output.contains("Boat usage:"));
|
||||
assert!(!output.contains("Computer usage:"));
|
||||
// Another run's record is refused.
|
||||
let transport = FakeTransport::new(vec![response(
|
||||
200,
|
||||
@@ -4110,7 +4122,8 @@ fn work_quote_builds_the_c5_request_from_served_records_and_prints_the_disclosur
|
||||
"defaultRepoProvider": "github", "defaultRepo": "octo-org/app"
|
||||
}] }),
|
||||
),
|
||||
response(200, boat_quote()),
|
||||
response(200, work_offer()),
|
||||
response(200, work_quote()),
|
||||
]);
|
||||
let (result, output) = run_agent_command(&transport, &secrets, "E_ARGV);
|
||||
result.unwrap();
|
||||
@@ -4124,16 +4137,17 @@ fn work_quote_builds_the_c5_request_from_served_records_and_prints_the_disclosur
|
||||
format!("/api/runs/{WORK_ID}").as_str(),
|
||||
"/api/agents",
|
||||
"/api/projects",
|
||||
"/api/sandbox/work-offer",
|
||||
"/api/sandbox/launch-quote"
|
||||
]
|
||||
);
|
||||
assert_eq!(requests[3].method, HttpMethod::Post);
|
||||
assert_eq!(requests[4].method, HttpMethod::Post);
|
||||
assert_eq!(
|
||||
body_of(&requests[3]),
|
||||
body_of(&requests[4]),
|
||||
json!({
|
||||
"workRunId": WORK_ID, "agentId": "agent-1", "projectId": "project-codewhale",
|
||||
"repo": "octo-org/app", "provider": "github", "prompt": "Fix the build",
|
||||
"runnerKind": "hosted", "sandboxSku": "boat-small", "estimatedSeconds": 300,
|
||||
"runnerKind": "hosted", "sandboxSku": "task-small", "estimatedSeconds": 300,
|
||||
"modelProvider": "deepseek", "model": "deepseek-flash",
|
||||
"billingMode": "byok_external", "computeRegion": "eu",
|
||||
"sandboxTargetRegion": "eu", "crossRegionSandboxOptIn": true,
|
||||
@@ -4144,12 +4158,12 @@ fn work_quote_builds_the_c5_request_from_served_records_and_prints_the_disclosur
|
||||
"Nothing has started and nothing is charged.",
|
||||
"Repository: octo-org/app",
|
||||
"deepseek/deepseek-flash",
|
||||
"boat-small on Boat in the EU, up to 300 seconds",
|
||||
"Funding: provider trial; Codewhale credits charged: $0",
|
||||
"small cloud computer in the EU, up to 300 seconds",
|
||||
"Funding: trial credit; Codewhale credits charged: $0",
|
||||
"Provider cost estimate: $0.0207",
|
||||
"EU placement",
|
||||
"Run this five-minute Boat trial Work?",
|
||||
"Boat trial credit covers its computer time.[31m",
|
||||
"Run this five-minute trial Work?",
|
||||
"Trial credit covers its computer time.[31m",
|
||||
"Confirmation: tok.abc-123_DEF",
|
||||
"Operation key: launch-1",
|
||||
&format!(
|
||||
@@ -4184,7 +4198,7 @@ fn work_quote_refuses_before_quoting_anything_it_cannot_launch_or_would_charge_f
|
||||
let (result, _) = run_agent_command(&transport, &secrets, "E_ARGV);
|
||||
assert!(chain(&result.unwrap_err()).contains("no longer bound to this Work's Project"));
|
||||
|
||||
// A non-GitHub Work cannot use the Boat trial.
|
||||
// A non-GitHub Work cannot use cloud Work.
|
||||
let mut cnb = work_run("queued");
|
||||
cnb["run"]["repoProvider"] = json!("cnb");
|
||||
let transport = FakeTransport::new(vec![response(200, cnb)]);
|
||||
@@ -4198,11 +4212,10 @@ fn work_quote_refuses_before_quoting_anything_it_cannot_launch_or_would_charge_f
|
||||
quote["disclosure"]["customerCreditsChargedUsd"] = json!(0.4)
|
||||
},
|
||||
|quote: &mut serde_json::Value| quote["disclosure"]["funding"] = json!("membership"),
|
||||
|quote: &mut serde_json::Value| quote["quote"]["sku"] = json!("boat-large"),
|
||||
|quote: &mut serde_json::Value| quote["quote"]["sku"] = json!("task-large"),
|
||||
|quote: &mut serde_json::Value| {
|
||||
quote["disclosure"]["sandboxTargetRegion"] = json!("us-west")
|
||||
},
|
||||
|quote: &mut serde_json::Value| quote["quote"]["adapter"] = json!("other"),
|
||||
|quote: &mut serde_json::Value| {
|
||||
quote["disclosure"]["computerTime"]["estimatedSeconds"] = json!(301)
|
||||
},
|
||||
@@ -4211,7 +4224,7 @@ fn work_quote_refuses_before_quoting_anything_it_cannot_launch_or_would_charge_f
|
||||
quote["disclosure"]["modelInference"]["billing"] = json!("managed_wallet")
|
||||
},
|
||||
] {
|
||||
let mut quote = boat_quote();
|
||||
let mut quote = work_quote();
|
||||
tamper(&mut quote);
|
||||
let transport = FakeTransport::new(vec![
|
||||
response(200, work_run("queued")),
|
||||
@@ -4223,6 +4236,7 @@ fn work_quote_refuses_before_quoting_anything_it_cannot_launch_or_would_charge_f
|
||||
"defaultRepoProvider": "github", "defaultRepo": "octo-org/app"
|
||||
}] }),
|
||||
),
|
||||
response(200, work_offer()),
|
||||
response(200, quote),
|
||||
]);
|
||||
let (result, output) = run_agent_command(&transport, &secrets, "E_ARGV);
|
||||
@@ -4235,21 +4249,23 @@ fn work_quote_refuses_before_quoting_anything_it_cannot_launch_or_would_charge_f
|
||||
reply(200, work_run("queued")),
|
||||
agents_step(),
|
||||
projects_step(),
|
||||
reply(404, json!({ "code": "boat_work_trial_unavailable" })),
|
||||
reply(200, work_offer()),
|
||||
reply(404, json!({ "code": "work_vm_trial_unavailable" })),
|
||||
]);
|
||||
let (result, _) = run_agent_command(&transport, &secrets, "E_ARGV);
|
||||
let message = chain(&result.unwrap_err());
|
||||
assert!(message.contains("boat_work_trial_unavailable"));
|
||||
assert!(message.contains("work_vm_trial_unavailable"));
|
||||
assert!(message.contains("not available for this account"));
|
||||
|
||||
// The printed retry command must not carry remote shell metacharacters.
|
||||
for token in ["tok.$(command)", "tok.`command`", "tok.abc;command"] {
|
||||
let mut quote = boat_quote();
|
||||
let mut quote = work_quote();
|
||||
quote["confirmation"]["token"] = json!(token);
|
||||
let transport = ScriptedTransport::new(vec![
|
||||
reply(200, work_run("queued")),
|
||||
agents_step(),
|
||||
projects_step(),
|
||||
reply(200, work_offer()),
|
||||
reply(200, quote),
|
||||
]);
|
||||
let (result, output) = run_agent_command(&transport, &secrets, "E_ARGV);
|
||||
@@ -4341,7 +4357,17 @@ fn work_confirmation_stdin_proof_reaches_only_the_confirmed_request() {
|
||||
let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
|
||||
let proof = work::read_confirmation(&b"consent.private-proof\n"[..]).unwrap();
|
||||
let mut output = Vec::new();
|
||||
work::launch(&client, &mut output, WORK_ID, "launch-stdin", &proof, true).unwrap();
|
||||
work::launch(
|
||||
&client,
|
||||
&mut output,
|
||||
WORK_ID,
|
||||
"launch-stdin",
|
||||
&proof,
|
||||
true,
|
||||
Some(300),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
body_of(&transport.requests()[3])["launchQuoteConfirmation"],
|
||||
proof
|
||||
@@ -4395,7 +4421,7 @@ fn work_launch_requires_eu_consent_and_sends_the_confirmed_c5_request() {
|
||||
json!({
|
||||
"workRunId": WORK_ID, "agentId": "agent-1", "projectId": "project-codewhale",
|
||||
"repo": "octo-org/app", "provider": "github", "prompt": "Fix the build",
|
||||
"runnerKind": "hosted", "sandboxSku": "boat-small", "estimatedSeconds": 300,
|
||||
"runnerKind": "hosted", "sandboxSku": "task-small", "estimatedSeconds": 300,
|
||||
"modelProvider": "deepseek", "model": "deepseek-flash",
|
||||
"billingMode": "byok_external", "computeRegion": "eu",
|
||||
"sandboxTargetRegion": "eu", "crossRegionSandboxOptIn": true,
|
||||
@@ -4405,10 +4431,10 @@ fn work_launch_requires_eu_consent_and_sends_the_confirmed_c5_request() {
|
||||
})
|
||||
);
|
||||
for expected in [
|
||||
"Work launched on bounded Boat trial compute.",
|
||||
"Work launched on a cloud computer.",
|
||||
&format!("Work ID: {WORK_ID}"),
|
||||
"Status: planning",
|
||||
"Computer: boat (running)",
|
||||
"Computer: running",
|
||||
"Compute region: eu",
|
||||
"Codewhale credits charged: $0",
|
||||
"Attempt: accepted",
|
||||
|
||||
+320
-65
@@ -1,5 +1,5 @@
|
||||
//! The account Work journey: assign Work, cancel it, read its outcome, quote
|
||||
//! and launch bounded Boat trial compute, and connect a GitHub repository.
|
||||
//! and launch offered cloud compute, and connect a GitHub repository.
|
||||
//!
|
||||
//! Everything here is a thin, honest client of the account control plane.
|
||||
//! Remote text is untrusted: it only reaches the terminal through `printable`,
|
||||
@@ -11,16 +11,62 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The bounded Boat trial this CLI can start. These are the contract values
|
||||
/// The cloud Work profile this CLI can start. These are the contract values
|
||||
/// the control plane admits; anything else is refused by the server, so the
|
||||
/// CLI states them instead of exposing knobs that could only fail.
|
||||
const BOAT_TRIAL_SKU: &str = "boat-small";
|
||||
const BOAT_TRIAL_SECONDS: u64 = 300;
|
||||
const BOAT_TRIAL_MODEL_PROVIDER: &str = "deepseek";
|
||||
const BOAT_TRIAL_MODEL: &str = "deepseek-flash";
|
||||
const WORK_SKU: &str = "task-small";
|
||||
const WORK_MODEL_PROVIDER: &str = "deepseek";
|
||||
const WORK_MODEL: &str = "deepseek-flash";
|
||||
const MAX_CONFIRMATION_BYTES: usize = 4096;
|
||||
const MAX_CANCEL_REASON_CHARS: usize = 500;
|
||||
|
||||
pub(super) fn task_context(
|
||||
path: Option<&Path>,
|
||||
refs: &[String],
|
||||
) -> Result<Option<WorkTaskContext>> {
|
||||
if refs.len() > 10 {
|
||||
bail!("Reference at most ten saved files per task");
|
||||
}
|
||||
let text = match path {
|
||||
Some(path) => {
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(path)
|
||||
.context("Could not open the task source context file")?
|
||||
.take(256_001)
|
||||
.read_to_end(&mut bytes)?;
|
||||
if bytes.len() > 256_000 {
|
||||
bail!("Task source context exceeds 64000 UTF-16 units");
|
||||
}
|
||||
let text = String::from_utf8(bytes).context("Task source context must be UTF-8")?;
|
||||
if text.encode_utf16().count() > 64_000 {
|
||||
bail!("Task source context exceeds 64000 UTF-16 units");
|
||||
}
|
||||
text
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
let mut file_refs: Vec<WorkFileRef> = Vec::new();
|
||||
for value in refs {
|
||||
let (id, version) = value
|
||||
.rsplit_once('@')
|
||||
.ok_or_else(|| anyhow!("Saved file references must be FILE_ID@VERSION"))?;
|
||||
let file_id = validate_account_file_id(id)?.to_string();
|
||||
let version: u64 = version
|
||||
.parse()
|
||||
.context("Saved file version must be a positive integer")?;
|
||||
if version == 0
|
||||
|| version > 9_007_199_254_740_991
|
||||
|| file_refs.iter().any(|ref_| ref_.file_id == file_id)
|
||||
{
|
||||
bail!(
|
||||
"Saved file references need distinct file IDs and positive safe-integer versions"
|
||||
);
|
||||
}
|
||||
file_refs.push(WorkFileRef { file_id, version });
|
||||
}
|
||||
Ok((!text.is_empty() || !file_refs.is_empty()).then_some(WorkTaskContext { text, file_refs }))
|
||||
}
|
||||
|
||||
fn valid_confirmation(token: &str) -> bool {
|
||||
!token.is_empty()
|
||||
&& token.len() <= MAX_CONFIRMATION_BYTES
|
||||
@@ -166,10 +212,11 @@ pub(super) fn assign<T: CloudTransport, W: Write>(
|
||||
agent: &AccountAgent,
|
||||
objective: &str,
|
||||
message_id: &str,
|
||||
task_context: Option<&WorkTaskContext>,
|
||||
) -> Result<()> {
|
||||
let message_id = validate_operation_key(message_id)?;
|
||||
let receipt = client
|
||||
.assign_agent_work(&agent.id, objective, message_id)
|
||||
.assign_agent_work(&agent.id, objective, message_id, task_context)
|
||||
.map_err(|err| {
|
||||
if outcome_unknown(&err) {
|
||||
err.context(format!(
|
||||
@@ -185,6 +232,12 @@ pub(super) fn assign<T: CloudTransport, W: Write>(
|
||||
if item.agent_id != agent.id {
|
||||
bail!("The Codewhale service returned Work for a different Agent");
|
||||
}
|
||||
if let Some(context) = task_context {
|
||||
let digest = crate::update::sha256_hex(&serde_json::to_vec(context)?);
|
||||
if item.task_context_digest != digest {
|
||||
return Err(CloudTransportError::new("The Work reply does not confirm the requested source context; replay the same message and context", std::io::Error::other("task context mismatch")).into());
|
||||
}
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
let intent = receipt.intent.as_str();
|
||||
@@ -251,7 +304,7 @@ pub(super) fn assign<T: CloudTransport, W: Write>(
|
||||
("actionable" | "queued", Some(first)) => {
|
||||
writeln!(
|
||||
out,
|
||||
"Work is recorded, not started. To run it on bounded Boat trial compute, review a quote first:"
|
||||
"Work is recorded, not started. To run it on a cloud computer, review a quote first:"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
@@ -417,11 +470,13 @@ fn usd(value: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_boat_usage<W: Write>(out: &mut W, envelope: &Value, result: &Value) -> Result<()> {
|
||||
fn write_compute_usage<W: Write>(out: &mut W, envelope: &Value, result: &Value) -> Result<()> {
|
||||
let block = [
|
||||
at(result, &["computeUsage"]),
|
||||
at(result, &["boatUsage"]),
|
||||
at(result, &["providerUsage"]),
|
||||
at(result, &["usage"]),
|
||||
at(envelope, &["computeUsage"]),
|
||||
at(envelope, &["boatUsage"]),
|
||||
at(envelope, &["providerUsage"]),
|
||||
at(envelope, &["usage"]),
|
||||
@@ -432,7 +487,7 @@ fn write_boat_usage<W: Write>(out: &mut W, envelope: &Value, result: &Value) ->
|
||||
let Some(block) = block else {
|
||||
return Ok(());
|
||||
};
|
||||
writeln!(out, "Boat usage:")?;
|
||||
writeln!(out, "Computer usage:")?;
|
||||
let number = |keys: &[&str]| {
|
||||
keys.iter()
|
||||
.find_map(|key| block.get(*key).and_then(Value::as_f64))
|
||||
@@ -620,7 +675,7 @@ fn write_work_result<W: Write>(
|
||||
Some((provider, model)) => writeln!(out, "Model route: {provider}/{model}")?,
|
||||
None => writeln!(out, "Model route: not reported by the account API")?,
|
||||
}
|
||||
write_boat_usage(out, envelope, result)?;
|
||||
write_compute_usage(out, envelope, result)?;
|
||||
match attempts {
|
||||
Some(attempts) => {
|
||||
let rows = at(attempts, &["attempts"])
|
||||
@@ -687,6 +742,65 @@ struct LaunchTarget {
|
||||
repo: String,
|
||||
prompt: String,
|
||||
state: String,
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
struct WorkOffer {
|
||||
funding: String,
|
||||
min_seconds: u64,
|
||||
max_seconds: u64,
|
||||
remaining_seconds: Option<u64>,
|
||||
empty_workspace: bool,
|
||||
}
|
||||
|
||||
fn default_seconds(offer: &WorkOffer) -> u64 {
|
||||
offer
|
||||
.remaining_seconds
|
||||
.map_or(offer.max_seconds, |remaining| {
|
||||
remaining.min(offer.max_seconds)
|
||||
})
|
||||
}
|
||||
|
||||
fn load_work_offer<T: CloudTransport>(client: &CloudClient<'_, T>) -> Result<WorkOffer> {
|
||||
let value: Value = expect_json(
|
||||
client.execute_authenticated(HttpMethod::Get, "/api/sandbox/work-offer", None)?,
|
||||
&[200],
|
||||
)?;
|
||||
let offer = value.get("offer").unwrap_or(&value);
|
||||
let min_seconds = count_at(offer, &["minSeconds"]).unwrap_or(0);
|
||||
let max_seconds = count_at(offer, &["maxSeconds"]).unwrap_or(0);
|
||||
let funding = str_at(offer, &["funding"]).unwrap_or_default();
|
||||
let mode_matches = matches!(
|
||||
(str_at(offer, &["mode"]), funding),
|
||||
(Some("trial"), "trial_credit") | (Some("open"), "compute_cu")
|
||||
);
|
||||
if !mode_matches
|
||||
|| min_seconds == 0
|
||||
|| max_seconds < min_seconds
|
||||
|| max_seconds > 9_007_199_254_740_991
|
||||
|| str_at(offer, &["sku"]) != Some(WORK_SKU)
|
||||
|| str_at(offer, &["region"]) != Some("eu")
|
||||
|| str_at(offer, &["model", "provider"]) != Some(WORK_MODEL_PROVIDER)
|
||||
|| str_at(offer, &["model", "model"]) != Some(WORK_MODEL)
|
||||
{
|
||||
bail!("The account did not serve a supported cloud Work offer; nothing started");
|
||||
}
|
||||
let remaining_seconds = match offer.get("computeRemainingSeconds") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(value) => Some(
|
||||
value
|
||||
.as_u64()
|
||||
.filter(|seconds| *seconds <= 9_007_199_254_740_991)
|
||||
.ok_or_else(|| anyhow!("The computer balance in the Work offer is invalid"))?,
|
||||
),
|
||||
};
|
||||
Ok(WorkOffer {
|
||||
funding: funding.to_string(),
|
||||
min_seconds,
|
||||
max_seconds,
|
||||
remaining_seconds,
|
||||
empty_workspace: offer.get("emptyWorkspace").and_then(Value::as_bool) == Some(true),
|
||||
})
|
||||
}
|
||||
|
||||
/// The Work as the account serves it: the launch acts on this record, not on
|
||||
@@ -710,7 +824,7 @@ fn load_launch_target<T: CloudTransport>(
|
||||
let provider = field("repoProvider").to_ascii_lowercase();
|
||||
if !provider.is_empty() && provider != "github" {
|
||||
bail!(
|
||||
"Boat trial Work supports GitHub repositories only; this Work uses {}",
|
||||
"Cloud Work supports GitHub repositories only; this Work uses {}",
|
||||
printable(&provider)
|
||||
);
|
||||
}
|
||||
@@ -734,6 +848,7 @@ fn load_launch_target<T: CloudTransport>(
|
||||
repo: field("repo"),
|
||||
prompt,
|
||||
state: field("state"),
|
||||
workspace_id: field("workspaceId"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -755,14 +870,32 @@ fn verify_launch_authority<T: CloudTransport>(
|
||||
.context("The Codewhale service returned an invalid Project list")?;
|
||||
let project = projects
|
||||
.projects
|
||||
.iter()
|
||||
.find(|project| project.id == target.project_id)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Project {} is not available on this account. Run `codewhale account projects list`",
|
||||
target.project_id
|
||||
)
|
||||
})?;
|
||||
.into_iter()
|
||||
.find(|project| project.id == target.project_id);
|
||||
let project = match project {
|
||||
Some(project) => project,
|
||||
None => {
|
||||
let id = validate_resource_id(&target.project_id, "Project")?;
|
||||
let reply: ProjectResponse = expect_json(
|
||||
client.execute_authenticated(
|
||||
HttpMethod::Get,
|
||||
&format!("/api/projects/{id}"),
|
||||
None,
|
||||
)?,
|
||||
&[200],
|
||||
)?;
|
||||
if reply.project.id != target.project_id {
|
||||
bail!("The Codewhale service returned a different Work Project");
|
||||
}
|
||||
reply.project
|
||||
}
|
||||
};
|
||||
if target.repo.is_empty()
|
||||
&& project.default_repo.is_empty()
|
||||
&& project.default_repo_provider.is_empty()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if project.default_repo_provider != "github" || project.default_repo.trim().is_empty() {
|
||||
bail!(
|
||||
"Project {} has no GitHub repository, so its Work cannot be launched",
|
||||
@@ -781,34 +914,53 @@ fn verify_launch_authority<T: CloudTransport>(
|
||||
}
|
||||
|
||||
/// Contract C5: the launch-quote and cloud-sessions request for one bounded
|
||||
/// Boat trial. The quote and the launch send the same fields so the signed
|
||||
/// cloud task. The quote and the launch send the same fields so the signed
|
||||
/// confirmation binds to exactly what starts.
|
||||
fn launch_body(target: &LaunchTarget, operation_key: &str) -> Result<Value> {
|
||||
Ok(json!({
|
||||
fn launch_body(
|
||||
target: &LaunchTarget,
|
||||
operation_key: &str,
|
||||
seconds: u64,
|
||||
recovery_from: Option<&str>,
|
||||
) -> Result<Value> {
|
||||
if seconds == 0 || seconds > 9_007_199_254_740_991 {
|
||||
bail!("Task time must be a positive safe-integer number of seconds");
|
||||
}
|
||||
let mut body = json!({
|
||||
"workRunId": target.run_id,
|
||||
"agentId": target.agent_id,
|
||||
"projectId": target.project_id,
|
||||
"repo": validate_github_repo(&target.repo)?,
|
||||
"provider": "github",
|
||||
"prompt": target.prompt,
|
||||
"runnerKind": "hosted",
|
||||
"sandboxSku": BOAT_TRIAL_SKU,
|
||||
"estimatedSeconds": BOAT_TRIAL_SECONDS,
|
||||
"modelProvider": BOAT_TRIAL_MODEL_PROVIDER,
|
||||
"model": BOAT_TRIAL_MODEL,
|
||||
"sandboxSku": WORK_SKU,
|
||||
"estimatedSeconds": seconds,
|
||||
"modelProvider": WORK_MODEL_PROVIDER,
|
||||
"model": WORK_MODEL,
|
||||
"billingMode": "byok_external",
|
||||
"computeRegion": "eu",
|
||||
"sandboxTargetRegion": "eu",
|
||||
"crossRegionSandboxOptIn": true,
|
||||
"operationKey": operation_key,
|
||||
}))
|
||||
});
|
||||
if target.repo.is_empty() {
|
||||
body["workspaceSource"] = json!("empty");
|
||||
} else {
|
||||
body["repo"] = json!(validate_github_repo(&target.repo)?);
|
||||
body["provider"] = json!("github");
|
||||
}
|
||||
if let Some(previous) = recovery_from {
|
||||
body["recovery"] = json!(true);
|
||||
body["expectedPreviousWorkspaceId"] = json!(validate_resource_id(previous, "Workspace")?);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// What to do next for a control-plane code the launch flow knows about.
|
||||
fn launch_hint(code: &str) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
"boat_work_trial_unavailable" => "Boat trial Work is not available for this account",
|
||||
"boat_work_provider_unavailable" => "Boat compute is not configured on this Codewhale API",
|
||||
"work_vm_trial_unavailable" => "Cloud Work is not available for this account",
|
||||
"work_vm_provider_unavailable" => {
|
||||
"Cloud computers are not configured on this Codewhale API"
|
||||
}
|
||||
"hosted_launch_quote_required"
|
||||
| "hosted_launch_quote_invalid"
|
||||
| "hosted_launch_quote_expired"
|
||||
@@ -837,18 +989,50 @@ pub(super) fn quote<T: CloudTransport, W: Write>(
|
||||
out: &mut W,
|
||||
id: &str,
|
||||
operation_key: &str,
|
||||
seconds: Option<u64>,
|
||||
recovery_from: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let id = validate_work_uuid(id)?;
|
||||
let operation_key = validate_operation_key(operation_key)?;
|
||||
let mut target = load_launch_target(client, id)?;
|
||||
if target.state != "queued" {
|
||||
let recovery_from = recovery_from
|
||||
.map(|id| validate_resource_id(id, "Workspace"))
|
||||
.transpose()?;
|
||||
if recovery_from.is_some() {
|
||||
if target.state != "failed" || recovery_from != Some(target.workspace_id.as_str()) {
|
||||
bail!(
|
||||
"Recovery needs the exact failed Work workspace; refresh work-status before reviewing a replacement"
|
||||
);
|
||||
}
|
||||
} else if target.state != "queued" {
|
||||
bail!(
|
||||
"Work is {}, and only queued Work can be quoted for launch. Run `codewhale account agents work-status {id}`",
|
||||
printable(&target.state)
|
||||
);
|
||||
}
|
||||
verify_launch_authority(client, &mut target)?;
|
||||
let body = launch_body(&target, operation_key)?;
|
||||
if recovery_from.is_none() {
|
||||
verify_launch_authority(client, &mut target)?;
|
||||
}
|
||||
let offer = load_work_offer(client)?;
|
||||
let seconds = seconds.unwrap_or_else(|| default_seconds(&offer));
|
||||
if seconds < offer.min_seconds || seconds > offer.max_seconds {
|
||||
bail!(
|
||||
"Task time must be within the served offer: {}-{} seconds",
|
||||
offer.min_seconds,
|
||||
offer.max_seconds
|
||||
);
|
||||
}
|
||||
if target.repo.is_empty() && !offer.empty_workspace {
|
||||
bail!("The account's Work offer does not support an empty workspace");
|
||||
}
|
||||
if offer.funding == "compute_cu"
|
||||
&& offer
|
||||
.remaining_seconds
|
||||
.is_some_and(|remaining| remaining < seconds)
|
||||
{
|
||||
bail!("The computer balance does not cover the reviewed task time");
|
||||
}
|
||||
let body = launch_body(&target, operation_key, seconds, recovery_from)?;
|
||||
let response = client.execute_authenticated(
|
||||
HttpMethod::Post,
|
||||
"/api/sandbox/launch-quote",
|
||||
@@ -863,48 +1047,87 @@ pub(super) fn quote<T: CloudTransport, W: Write>(
|
||||
}
|
||||
let quote: Value = parse_json_body(&response.body)?;
|
||||
|
||||
// This command starts only the $0 EU Boat trial. If the service quotes
|
||||
// anything else, refuse to hand out a confirmation for it.
|
||||
let seconds = count_at("e, &["disclosure", "computerTime", "estimatedSeconds"]);
|
||||
let quoted_seconds = count_at("e, &["disclosure", "computerTime", "estimatedSeconds"]);
|
||||
let expected = at("e, &["disclosure", "funding"]).and_then(Value::as_str)
|
||||
== Some("provider_trial")
|
||||
== Some(offer.funding.as_str())
|
||||
&& at("e, &["disclosure", "customerCreditsChargedUsd"]).and_then(Value::as_f64)
|
||||
== Some(0.0)
|
||||
&& str_at("e, &["quote", "sku"]) == Some(BOAT_TRIAL_SKU)
|
||||
&& str_at("e, &["quote", "adapter"]) == Some("boat")
|
||||
&& str_at("e, &["quote", "sku"]) == Some(WORK_SKU)
|
||||
&& str_at("e, &["disclosure", "sandboxTargetRegion"]) == Some("eu")
|
||||
&& str_at("e, &["disclosure", "modelInference", "billing"]) == Some("byok_external")
|
||||
&& seconds == Some(BOAT_TRIAL_SECONDS);
|
||||
&& quoted_seconds == Some(seconds)
|
||||
&& (offer.funding != "compute_cu"
|
||||
|| (at(
|
||||
"e,
|
||||
&["disclosure", "computerTime", "coveredByComputeBalance"],
|
||||
)
|
||||
.and_then(Value::as_bool)
|
||||
== Some(true)
|
||||
&& count_at(
|
||||
"e,
|
||||
&["disclosure", "computerTime", "remainingAfterSeconds"],
|
||||
)
|
||||
.is_some()))
|
||||
&& (if target.repo.is_empty() {
|
||||
str_at("e, &["workspace", "source"]) == Some("empty")
|
||||
&& at("e, &["workspace", "clone"]).and_then(Value::as_bool) == Some(false)
|
||||
&& at("e, &["workspace", "repository"]).is_none_or(Value::is_null)
|
||||
} else {
|
||||
str_at("e, &["workspace", "source"]) != Some("empty")
|
||||
});
|
||||
if !expected {
|
||||
bail!(
|
||||
"The Codewhale service quoted something other than the $0 five-minute EU Boat trial. Refusing to print a confirmation for it; nothing started"
|
||||
"The Codewhale service quoted different terms from the reviewed Work offer. Refusing to print a confirmation for it; nothing started"
|
||||
);
|
||||
}
|
||||
let token = str_at("e, &["confirmation", "token"]).unwrap_or_default();
|
||||
if !valid_confirmation(token)
|
||||
|| str_at("e, &["confirmation", "workRunId"]).is_some_and(|quoted| quoted != id)
|
||||
|| recovery_from.is_some_and(|previous| {
|
||||
str_at("e, &["confirmation", "workRunId"]) != Some(id)
|
||||
|| at("e, &["confirmation", "recovery"]).and_then(Value::as_bool) != Some(true)
|
||||
|| str_at("e, &["confirmation", "expectedPreviousWorkspaceId"])
|
||||
!= Some(previous)
|
||||
})
|
||||
{
|
||||
bail!("The Codewhale service returned an unusable launch confirmation");
|
||||
}
|
||||
let seconds = BOAT_TRIAL_SECONDS;
|
||||
writeln!(
|
||||
out,
|
||||
"Boat trial Work quote. Nothing has started and nothing is charged."
|
||||
"Cloud Work quote. Nothing has started and nothing is charged."
|
||||
)?;
|
||||
writeln!(out, "Work ID: {id}")?;
|
||||
writeln!(out, "Repository: {}", printable(&target.repo))?;
|
||||
if target.repo.is_empty() {
|
||||
writeln!(out, "Workspace: empty; no repository will be cloned")?;
|
||||
} else {
|
||||
writeln!(out, "Repository: {}", printable(&target.repo))?;
|
||||
}
|
||||
if let Some(previous) = recovery_from {
|
||||
writeln!(
|
||||
out,
|
||||
"Recovery: fresh computer; Work ID retained; previous workspace {}",
|
||||
printable(previous)
|
||||
)?;
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"Model: {BOAT_TRIAL_MODEL_PROVIDER}/{BOAT_TRIAL_MODEL} with your own DeepSeek key; DeepSeek bills your BYOK usage directly"
|
||||
"Model: {WORK_MODEL_PROVIDER}/{WORK_MODEL} with your own DeepSeek key; DeepSeek bills your BYOK usage directly"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
"Computer: {BOAT_TRIAL_SKU} on Boat in the EU, up to {seconds} seconds"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
"Funding: provider trial; Codewhale credits charged: $0"
|
||||
"Computer: small cloud computer in the EU, up to {seconds} seconds"
|
||||
)?;
|
||||
if offer.funding == "compute_cu" {
|
||||
writeln!(
|
||||
out,
|
||||
"Funding: account computer balance; up to {seconds} seconds; Codewhale credits charged: $0"
|
||||
)?;
|
||||
if let Some(remaining) = offer.remaining_seconds {
|
||||
writeln!(out, "Computer balance at review: {remaining} seconds")?;
|
||||
}
|
||||
} else {
|
||||
writeln!(out, "Funding: trial credit; Codewhale credits charged: $0")?;
|
||||
}
|
||||
if let Some(estimate) =
|
||||
at("e, &["disclosure", "providerCostEstimateUsd"]).and_then(Value::as_f64)
|
||||
{
|
||||
@@ -912,7 +1135,7 @@ pub(super) fn quote<T: CloudTransport, W: Write>(
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"EU placement: Codewhale admission attestation; Boat reports no region field. Repository code and Work files are admitted to EU compute."
|
||||
"EU placement: Codewhale admission attestation. Repository code and Work files are admitted to EU compute."
|
||||
)?;
|
||||
if let Some(title) = text_at("e, &["confirmCopy", "title"]) {
|
||||
writeln!(out, "{title}")?;
|
||||
@@ -928,9 +1151,12 @@ pub(super) fn quote<T: CloudTransport, W: Write>(
|
||||
}
|
||||
writeln!(out, "Confirmation: {token}")?;
|
||||
writeln!(out, "Operation key: {operation_key}")?;
|
||||
let recovery_args = recovery_from
|
||||
.map(|previous| format!(" --recovery-from {previous}"))
|
||||
.unwrap_or_default();
|
||||
writeln!(
|
||||
out,
|
||||
"To start: codewhale account agents work-launch {id} --operation-key {operation_key} --confirmation {token} --confirm-eu-compute"
|
||||
"To start: codewhale account agents work-launch {id} --operation-key {operation_key} --confirmation {token} --confirm-eu-compute --seconds {seconds}{recovery_args}"
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -943,10 +1169,12 @@ pub(super) fn launch<T: CloudTransport, W: Write>(
|
||||
operation_key: &str,
|
||||
confirmation: &str,
|
||||
confirm_eu_compute: bool,
|
||||
seconds: Option<u64>,
|
||||
recovery_from: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if !confirm_eu_compute {
|
||||
bail!(
|
||||
"Boat trial Work runs your repository code and Work files on Boat's EU compute. Nothing was sent; re-run with --confirm-eu-compute to agree"
|
||||
"Cloud Work runs your source context and Work files on a cloud computer in the EU. Nothing was sent; re-run with --confirm-eu-compute to agree"
|
||||
);
|
||||
}
|
||||
let id = validate_work_uuid(id)?;
|
||||
@@ -960,10 +1188,14 @@ pub(super) fn launch<T: CloudTransport, W: Write>(
|
||||
// Any other state is a replay of a launch that already ran (or a Work that
|
||||
// cannot launch); the control plane's operation ledger decides which, so
|
||||
// the same command stays safe to repeat after a lost reply.
|
||||
if target.state == "queued" {
|
||||
if target.state == "queued" && recovery_from.is_none() {
|
||||
verify_launch_authority(client, &mut target)?;
|
||||
}
|
||||
let mut body = launch_body(&target, operation_key)?;
|
||||
let seconds = match seconds {
|
||||
Some(seconds) => seconds,
|
||||
None => default_seconds(&load_work_offer(client)?),
|
||||
};
|
||||
let mut body = launch_body(&target, operation_key, seconds, recovery_from)?;
|
||||
body["launchQuoteConfirmation"] = json!(confirmation);
|
||||
body["customerEuPlacementConsent"] = json!(true);
|
||||
|
||||
@@ -1029,7 +1261,35 @@ pub(super) fn launch<T: CloudTransport, W: Write>(
|
||||
"The Codewhale service launched a different Work than requested. Run `codewhale account agents work-status {id}` before doing anything else"
|
||||
);
|
||||
}
|
||||
writeln!(out, "Work launched on bounded Boat trial compute.")?;
|
||||
if let Some(previous) = recovery_from {
|
||||
let workspace = str_at(session, &["workspace", "id"]);
|
||||
let matches = workspace.is_some_and(|workspace| {
|
||||
workspace != previous
|
||||
&& validate_resource_id(workspace, "Workspace").is_ok()
|
||||
&& str_at(session, &["attempt", "kind"]) == Some("recovery")
|
||||
&& str_at(session, &["attempt", "workRunId"]) == Some(id)
|
||||
&& str_at(session, &["attempt", "workspaceId"]) == Some(workspace)
|
||||
&& str_at(session, &["attempt", "previousWorkspaceId"]) == Some(previous)
|
||||
&& str_at(session, &["recovery", "workId"]) == Some(id)
|
||||
&& str_at(session, &["recovery", "workspaceId"]) == Some(workspace)
|
||||
&& str_at(session, &["recovery", "previousWorkspaceId"]) == Some(previous)
|
||||
});
|
||||
if !matches {
|
||||
return Err(refused(
|
||||
CloudTransportError::new(
|
||||
"The reply does not confirm the reviewed replacement attempt",
|
||||
std::io::Error::other("recovery identity mismatch"),
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"Work recovered on a fresh cloud computer; the original Work ID is retained."
|
||||
)?;
|
||||
} else {
|
||||
writeln!(out, "Work launched on a cloud computer.")?;
|
||||
}
|
||||
writeln!(out, "Work ID: {id}")?;
|
||||
writeln!(
|
||||
out,
|
||||
@@ -1039,13 +1299,8 @@ pub(super) fn launch<T: CloudTransport, W: Write>(
|
||||
if let Some(session_id) = text_at(session, &["id"]) {
|
||||
writeln!(out, "Session: {session_id}")?;
|
||||
}
|
||||
if let Some(provider) = text_at(session, &["sandbox", "provider"]) {
|
||||
writeln!(
|
||||
out,
|
||||
"Computer: {provider} ({})",
|
||||
text_at(session, &["sandbox", "status"])
|
||||
.unwrap_or_else(|| "status unknown".to_string())
|
||||
)?;
|
||||
if let Some(status) = text_at(session, &["sandbox", "status"]) {
|
||||
writeln!(out, "Computer: {status}")?;
|
||||
}
|
||||
if let Some(region) = text_at(session, &["sandboxTargetRegion"]) {
|
||||
writeln!(out, "Compute region: {region}")?;
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user