diff --git a/.github/workflows/tests-slow-nightly.yml b/.github/workflows/tests-slow-nightly.yml index bd77d5d13..2f4024c4d 100644 --- a/.github/workflows/tests-slow-nightly.yml +++ b/.github/workflows/tests-slow-nightly.yml @@ -85,7 +85,7 @@ jobs: cli_cluster_e2e::cluster_e2e_live_apply_changes_schema_queries_and_adds_graph_without_restart \ cli_cluster::core_live_apply_captures_server_file_root_without_local_storage_access \ cli_cluster::managed_lifecycle_wait_reports_tombstone_and_checks_every_poll_identity \ - cli_cluster::managed_apply_polls_original_delivery_without_resubmission_or_cancellation \ + cli_cluster::managed_plan_and_apply_poll_original_identity_without_resubmission_or_cancellation \ cli_cluster::managed_native_outcomes_preserve_activation_archive_and_uncertainty \ cli_cluster::managed_poll_identity_mismatch_retains_original_delivery_and_native_ids \ cli_cluster::managed_lifecycle_bad_acceptance_and_deadline_keep_recovery_identity \ diff --git a/changelog.d/managed-preview-polling.fixed.md b/changelog.d/managed-preview-polling.fixed.md new file mode 100644 index 000000000..ff815b1a9 --- /dev/null +++ b/changelog.d/managed-preview-polling.fixed.md @@ -0,0 +1 @@ +- Managed plans now poll the original preview until ready. Timeouts retain its ID, revision and key; `--no-wait` returns on acceptance without cancelling work. diff --git a/crates/omnigraph-cli/src/commands/cluster/args.rs b/crates/omnigraph-cli/src/commands/cluster/args.rs index 519d4a2de..342ebcb98 100644 --- a/crates/omnigraph-cli/src/commands/cluster/args.rs +++ b/crates/omnigraph-cli/src/commands/cluster/args.rs @@ -225,7 +225,7 @@ pub(crate) enum ClusterMembersCommand { #[derive(Debug, Default, Args)] pub(crate) struct ClusterRunArgs { - /// Return without waiting for completion. Managed apply acknowledges durable delivery reservation only. + /// Return without waiting for completion. Managed plan/apply acknowledge durable reservation only. #[arg(long)] pub(crate) no_wait: bool, /// For served apply or managed operations, bound the local wait in seconds (default 300, maximum 3600). diff --git a/crates/omnigraph-cli/src/managed.rs b/crates/omnigraph-cli/src/managed.rs index 7319d197d..58f3de3d5 100644 --- a/crates/omnigraph-cli/src/managed.rs +++ b/crates/omnigraph-cli/src/managed.rs @@ -129,9 +129,11 @@ impl Output { self.body["type"].as_str().unwrap_or("error") )?; for field in [ + "accepted_preview", "accepted_deployment", "requested_deployment_id", "submission", + "idempotency_key", ] { if let Some(value) = self.body.get(field) { writeln!(stderr, "{field}: {value}")?; @@ -140,6 +142,9 @@ impl Output { if self.body["type"] == "output_delivery_failed" { let recovery = recovery_evidence(&self.body); for field in ["accepted_operation", "idempotency_key", "original_error"] { + if field == "idempotency_key" && self.body.get(field).is_some() { + continue; + } if let Some(value) = recovery.get(field) { writeln!(stderr, "{field}: {value}")?; } @@ -192,6 +197,7 @@ fn accepted_recovery_output(body: &Value, recovery: Option<&Value>) -> Option Value { "title", "detail", "accepted_operation", + "accepted_preview", "accepted_deployment", "requested_deployment_id", "operation_id", diff --git a/crates/omnigraph-cli/src/managed/deployment.rs b/crates/omnigraph-cli/src/managed/deployment.rs index 6617d1a3a..bc7df0831 100644 --- a/crates/omnigraph-cli/src/managed/deployment.rs +++ b/crates/omnigraph-cli/src/managed/deployment.rs @@ -1,6 +1,8 @@ //! Native managed previews and delivery: submit once, observe the original IDs. use super::*; +mod preview; + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct Identity { deployment_id: String, @@ -341,7 +343,7 @@ pub(super) async fn dispatch( )); } let request = json!({"revision":revision}); - let (body, key) = submit( + let submission = submit( api, &format!("{base}/plans"), &request, @@ -350,9 +352,7 @@ pub(super) async fn dispatch( stderr, ) .await?; - let exit = preview_exit(&body, context, &revision) - .map_err(|error| retain_submission(error, &key, &request))?; - Ok((body, exit)) + preview::finish(api, context, submission, &revision, run, deadline, stderr).await } ClusterCommand::Apply { plan, run, .. } => { let plan = plan.as_deref().ok_or_else(|| { diff --git a/crates/omnigraph-cli/src/managed/deployment/preview.rs b/crates/omnigraph-cli/src/managed/deployment/preview.rs new file mode 100644 index 000000000..fa85fbdef --- /dev/null +++ b/crates/omnigraph-cli/src/managed/deployment/preview.rs @@ -0,0 +1,197 @@ +//! Observe one accepted preview without submitting a replacement. +use super::*; + +#[derive(Debug, PartialEq, Eq, Serialize)] +struct Preview { + preview_id: String, + revision: String, + first_attempt_deadline: Option, +} + +impl Preview { + fn read(body: &Value, context: &Context, revision: &str) -> Result { + preview_exit(body, context, revision)?; + if !body["data"]["failure_code"].is_null() + && body["data"]["failure_code"].as_str().is_none_or(|code| { + code.is_empty() + || code.len() > 128 + || !code.bytes().all(|byte| byte.is_ascii_graphic()) + }) + { + return Err(Failure::protocol()); + } + let deadline = match &body["data"]["first_attempt_deadline"] { + Value::Null if body["data"]["state"] != "capturing" => None, + Value::String(value) => { + provider_timestamp(value)?; + Some(value.clone()) + } + _ => return Err(Failure::protocol()), + }; + Ok(Self { + preview_id: body["data"]["preview_id"] + .as_str() + .ok_or_else(Failure::protocol)? + .to_owned(), + revision: revision.to_owned(), + first_attempt_deadline: deadline, + }) + } + + fn verify(&self, body: &Value, context: &Context) -> Result<()> { + if Self::read(body, context, &self.revision)? != *self { + return Err(Failure::refused( + "context_mismatch", + "preview observation changed the original ID, revision or deadline", + )); + } + Ok(()) + } + + fn retain(&self, mut error: Failure, key: &str) -> Failure { + error.body["accepted_preview"] = serde_json::to_value(self).expect("preview identity"); + error.body["idempotency_key"] = json!(key); + error + } + + fn diagnostic( + &self, + stderr: &mut dyn Write, + key: &str, + message: std::fmt::Arguments<'_>, + ) -> Result<()> { + write_diagnostic(stderr, message).map_err(|error| self.retain(error, key)) + } + + fn timed_out( + &self, + mut body: Value, + key: &str, + stderr: &mut dyn Write, + ) -> Result<(Value, i32)> { + self.diagnostic(stderr, key, format_args!( + "local wait deadline reached; preview {} remains accepted; repeat `cluster plan --rev {} --idempotency-key KEY --target NAME` with the original key retained in the response; no work was cancelled or resubmitted\n", + self.preview_id, self.revision + ))?; + body["idempotency_key"] = json!(key); + Ok((body, 5)) + } +} + +pub(super) async fn finish( + api: &Api, + context: &Context, + submission: (Value, String), + revision: &str, + run: &ClusterRunArgs, + deadline: Instant, + stderr: &mut dyn Write, +) -> Result<(Value, i32)> { + let (mut body, key) = submission; + let key = key.as_str(); + let preview = Preview::read(&body, context, revision) + .map_err(|error| retain_submission(error, key, &json!({"revision":revision})))?; + if run.no_wait { + let exit = preview_exit(&body, context, revision)?; + if exit == 5 || (exit == 0 && !body["data"]["native_plan"].is_object()) { + body["idempotency_key"] = json!(key); + } + return Ok((body, if exit == 5 { 0 } else { exit })); + } + let path = format!( + "/v1/clusters/{}/plans/{}", + context.cluster, preview.preview_id + ); + let mut observed = false; + let mut attempted_read = false; + loop { + preview + .verify(&body, context) + .map_err(|error| preview.retain(error, key))?; + let exit = + preview_exit(&body, context, revision).map_err(|error| preview.retain(error, key))?; + if exit == 1 { + let state = body["data"]["state"] + .as_str() + .ok_or_else(Failure::protocol)?; + let code = body["data"]["failure_code"] + .as_str() + .unwrap_or("preview_failed"); + preview.diagnostic( + stderr, + key, + format_args!( + "preview {}: {} ({}); a fresh plan requires a new idempotency key\n", + preview.preview_id, state, code + ), + )?; + return Ok((body, exit)); + } + if exit == 0 && body["data"]["native_plan"].is_object() { + return Ok((body, 0)); + } + if exit == 0 && observed { + return Err(preview.retain(Failure::protocol(), key)); + } + // A ready idempotent POST contains metadata only. Fetch its authorized + // plan immediately; pending previews use the normal bounded poll rate. + let next = if exit == 0 && !attempted_read { + Instant::now() + } else { + Instant::now() + POLL_INTERVAL + }; + if next >= deadline { + tokio::time::sleep_until(deadline).await; + return preview.timed_out(body, key, stderr); + } + preview.diagnostic( + stderr, + key, + format_args!( + "preview {} at revision {}: {}\n", + preview.preview_id, preview.revision, body["data"]["state"] + ), + )?; + tokio::time::sleep_until(next).await; + attempted_read = true; + match tokio::time::timeout_at(deadline, api.raw(Method::GET, &path, None, None)).await { + Ok(Ok(response)) if response.status.is_success() => { + body = response.body; + observed = true; + } + Ok(Ok(response)) + if response.status.is_server_error() + || response.status == StatusCode::TOO_MANY_REQUESTS => + { + preview.diagnostic(stderr, key, format_args!( + "preview observation unavailable; retain the original ID and continue bounded read-only lookup\n" + ))?; + } + Ok(Ok(response)) => { + return Err(preview.retain( + Failure { + body: response.body, + response_status: Some(response.status), + exit: if response.status.is_client_error() { + 2 + } else { + 1 + }, + }, + key, + )); + } + Ok(Err(error)) if error.body["type"] == "transport_failed" => { + preview.diagnostic( + stderr, + key, + format_args!( + "preview observation transport unavailable; retain the original ID\n" + ), + )?; + } + Ok(Err(error)) => return Err(preview.retain(error, key)), + Err(_) => return preview.timed_out(body, key, stderr), + } + } +} diff --git a/crates/omnigraph-cli/src/managed/tests.rs b/crates/omnigraph-cli/src/managed/tests.rs index 1a7d23290..26e354b1d 100644 --- a/crates/omnigraph-cli/src/managed/tests.rs +++ b/crates/omnigraph-cli/src/managed/tests.rs @@ -201,45 +201,56 @@ fn managed_output_preserves_bytes_and_recovery_details_on_delivery_failure() { } } - // A lifecycle identity receives the same narrow fallback. Extra fields and + // Accepted identities receive the same narrow fallback. Extra fields and // reflected error text must never turn recovery into source/secret export. - let identity = json!({"cluster_id":"cluster-original","incarnation":"incarnation-original","operation_id":"operation-original","kind":"create"}); - for fault in [OutputFault::PartialWrite, OutputFault::Flush] { - let mut body = json!({"type":"output_delivery_failed","detail":"private-error-content","accepted_operation":identity,"submission":{"idempotency_key":"original-key","request":{"source":"private-request-content"}},"access_token":"private-token-extra","data":{"files":{"secret.pg":"private-source-content"}}}); - body["accepted_operation"]["token"] = json!("private-identity-extra"); - let mut stdout = Vec::new(); - assert_eq!( - Output::from_result( - Err(Failure { - body, - exit: 1, - response_status: None - }), - false, + for (field, identity) in [ + ( + "accepted_operation", + json!({"cluster_id":"cluster-original","incarnation":"incarnation-original","operation_id":"operation-original","kind":"create"}), + ), + ( + "accepted_preview", + json!({"preview_id":"preview-original","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}), + ), + ] { + for fault in [OutputFault::PartialWrite, OutputFault::Flush] { + let mut body = json!({"type":"output_delivery_failed","detail":"private-error-content","submission":{"idempotency_key":"original-key","request":{"source":"private-request-content"}},"access_token":"private-token-extra","data":{"files":{"secret.pg":"private-source-content"}}}); + body[field] = identity.clone(); + body[field]["token"] = json!("private-identity-extra"); + let mut stdout = Vec::new(); + assert_eq!( + Output::from_result( + Err(Failure { + body, + exit: 1, + response_status: None + }), + false, + 1 + ) + .emit(&mut stdout, &mut FailedOutput::new(fault, 0)) + .unwrap(), 1 + ); + let recovery: Value = serde_json::from_slice(&stdout).unwrap(); + assert_eq!(recovery[field], identity); + assert_eq!(recovery["idempotency_key"], "original-key"); + assert!(!String::from_utf8(stdout).unwrap().contains("private-")); + + let mut stdout = Vec::new(); + let error = Output::from_result( + Err(Failure::refused( + "invalid_input", + "no operation was accepted", + )), + false, + 2, ) .emit(&mut stdout, &mut FailedOutput::new(fault, 0)) - .unwrap(), - 1 - ); - let recovery: Value = serde_json::from_slice(&stdout).unwrap(); - assert_eq!(recovery["accepted_operation"], identity); - assert_eq!(recovery["idempotency_key"], "original-key"); - assert!(!String::from_utf8(stdout).unwrap().contains("private-")); - - let mut stdout = Vec::new(); - let error = Output::from_result( - Err(Failure::refused( - "invalid_input", - "no operation was accepted", - )), - false, - 2, - ) - .emit(&mut stdout, &mut FailedOutput::new(fault, 0)) - .unwrap_err(); - assert!(stdout.is_empty()); - assert!(format!("{error:#}").contains("invalid_input")); + .unwrap_err(); + assert!(stdout.is_empty()); + assert!(format!("{error:#}").contains("invalid_input")); + } } } diff --git a/crates/omnigraph-cli/tests/integration/cli_cluster.rs b/crates/omnigraph-cli/tests/integration/cli_cluster.rs index e4ef2eec5..d4ec6f23c 100644 --- a/crates/omnigraph-cli/tests/integration/cli_cluster.rs +++ b/crates/omnigraph-cli/tests/integration/cli_cluster.rs @@ -31,7 +31,11 @@ fn lifecycle_record(root: &std::path::Path, origin: &str, name: &str) -> std::pa const MANAGED_REVISION: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; fn managed_preview(state: &str) -> Value { - serde_json::json!({"data":{"preview_id":"saved-plan","state":state,"revision":MANAGED_REVISION,"input_digest":"exact-input","generation":{"state_revision":3}},"meta":{"cluster_id":"managed-test","incarnation":"inc-one","provenance":"service_db"}}) + let mut body = serde_json::json!({"data":{"preview_id":"saved-plan","state":state,"revision":MANAGED_REVISION,"first_attempt_deadline":"2026-10-30T00:44:57Z","failure_code":null,"input_digest":"exact-input","generation":{"state_revision":3}},"meta":{"cluster_id":"managed-test","incarnation":"inc-one","provenance":"service_db"}}); + if state == "ready" { + body["data"]["native_plan"] = serde_json::json!({"ok":true,"changes":[]}); + } + body } fn managed_delivery(delivery: &str) -> Value { @@ -1959,7 +1963,7 @@ fn managed_plan_and_apply_submit_exact_native_intent_without_waiting() { vec!["plan", "--rev", MANAGED_REVISION], "/v1/clusters/managed-test/plans", serde_json::json!({"revision":MANAGED_REVISION}), - managed_preview("ready"), + managed_preview("capturing"), ), ( vec!["apply", "--plan", "saved-plan"], @@ -1978,7 +1982,11 @@ fn managed_plan_and_apply_submit_exact_native_intent_without_waiting() { "exact-key", "--json", ])); - assert_eq!(parse_stdout_json(&output), body); + let mut expected_output = body; + if path.ends_with("/plans") { + expected_output["idempotency_key"] = "exact-key".into(); + } + assert_eq!(parse_stdout_json(&output), expected_output); assert!(String::from_utf8_lossy(&output.stderr).contains("exact-key")); assert!(!String::from_utf8_lossy(&output.stderr).contains("og_fixture_control")); assert_control_request( @@ -2005,17 +2013,26 @@ fn managed_plan_omitted_revision_captures_public_source_head_once() { ]); write_managed_target(temp.path(), &api.origin); let output = managed_cli(temp.path(), &api.origin) - .args(["plan", "--idempotency-key", "snapshot", "--json"]) + .args([ + "plan", + "--no-wait", + "--idempotency-key", + "snapshot", + "--json", + ]) .output() .unwrap(); assert_eq!( output.status.code(), Some(match state { - "ready" => 0, - "capturing" => 5, + "ready" | "capturing" => 0, _ => 1, }) ); + let mut expected = expected; + if state == "capturing" { + expected["idempotency_key"] = "snapshot".into(); + } assert_eq!(parse_stdout_json(&output), expected); assert_control_request( &api.workflow_requests()[0], @@ -2037,68 +2054,216 @@ fn managed_plan_omitted_revision_captures_public_source_head_once() { } #[test] -#[ignore = "nightly: real 2 s delivery polling and a 1 s wait deadline"] -fn managed_apply_polls_original_delivery_without_resubmission_or_cancellation() { - for timeout in [false, true] { +fn managed_plan_replay_reads_only_the_original_authorized_preview() { + for case in [ + "ready", + "denied", + "other-id", + "other-revision", + "other-deadline", + "missing-plan", + ] { let temp = tempdir().unwrap(); - let queued = managed_delivery("queued"); - let complete = managed_complete(true, true, "archived"); - let replies = if timeout { - vec![IntentReply::json(202, queued.clone())] - } else { - vec![ - IntentReply::json(202, queued.clone()), - IntentReply::json(200, complete.clone()), - ] + let mut accepted = managed_preview("ready"); + accepted["data"] + .as_object_mut() + .unwrap() + .remove("native_plan"); + let mut observed = managed_preview("ready"); + let (status, exit) = match case { + "denied" => { + observed = serde_json::json!({"type":"native_authorization_denied","detail":"Current policy denies this preview"}); + (403, 2) + } + "other-id" => { + observed["data"]["preview_id"] = "another-preview".into(); + (200, 2) + } + "other-revision" => { + observed["data"]["revision"] = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(); + (200, 2) + } + "other-deadline" => { + observed["data"]["first_attempt_deadline"] = "2026-10-30T00:45:57Z".into(); + (200, 2) + } + "missing-plan" => { + observed["data"] + .as_object_mut() + .unwrap() + .remove("native_plan"); + (200, 1) + } + _ => (200, 0), }; - let api = lifecycle_fixture(replies); + let api = lifecycle_fixture(vec![ + IntentReply::json(200, accepted), + IntentReply::json(status, observed.clone()), + ]); write_managed_target(temp.path(), &api.origin); let output = managed_cli(temp.path(), &api.origin) .args([ - "apply", - "--plan", - "saved-plan", - "--timeout", - if timeout { "1" } else { "10" }, + "plan", + "--rev", + MANAGED_REVISION, "--idempotency-key", - "poll-key", + "original-plan-key", "--json", ]) .output() .unwrap(); - assert_eq!( - output.status.code(), - Some(if timeout { 5 } else { 0 }), - "{output:?}" - ); - assert_eq!( - parse_stdout_json(&output), - if timeout { queued } else { complete } - ); - if timeout { - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("cluster status delivery-one --target NAME") + assert_eq!(output.status.code(), Some(exit), "{case}: {output:?}"); + let body = parse_stdout_json(&output); + if exit == 0 { + assert_eq!(body, observed); + } else { + assert_eq!(body["accepted_preview"]["preview_id"], "saved-plan"); + assert_eq!(body["accepted_preview"]["revision"], MANAGED_REVISION); + assert_eq!(body["idempotency_key"], "original-plan-key"); + assert_eq!( + body["type"], + match case { + "denied" => "native_authorization_denied", + "missing-plan" => "api_response_invalid", + _ => "context_mismatch", + } ); } assert_control_request( &api.workflow_requests()[0], "POST", + "/v1/clusters/managed-test/plans", + serde_json::json!({"revision":MANAGED_REVISION}), + Some("original-plan-key"), + ); + assert_control_request( + &api.workflow_requests()[1], + "GET", + "/v1/clusters/managed-test/plans/saved-plan", + Value::Null, + None, + ); + api.assert_complete(); + assert_no_core_effects(temp.path()); + } +} + +#[test] +#[ignore = "nightly: real 2 s delivery polling and a 1 s wait deadline"] +fn managed_plan_and_apply_poll_original_identity_without_resubmission_or_cancellation() { + for (verb, selector, selected, queued, complete, path, lookup, request) in [ + ( + "plan", + "--rev", + MANAGED_REVISION, + managed_preview("capturing"), + managed_preview("ready"), + "/v1/clusters/managed-test/plans", + "/v1/clusters/managed-test/plans/saved-plan", + serde_json::json!({"revision":MANAGED_REVISION}), + ), + ( + "apply", + "--plan", + "saved-plan", + managed_delivery("queued"), + managed_complete(true, true, "archived"), "/v1/clusters/managed-test/deployments", + "/v1/clusters/managed-test/deployments/delivery-one", serde_json::json!({"preview_id":"saved-plan"}), - Some("poll-key"), - ); - if !timeout { + ), + ] { + for outcome in ["ready", "timeout", "failed", "expired", "unavailable"] { + if verb == "apply" && !matches!(outcome, "ready" | "timeout") { + continue; + } + let timeout = outcome == "timeout"; + let terminal_failure = matches!(outcome, "failed" | "expired"); + let mut complete = complete.clone(); + if terminal_failure { + complete = managed_preview(outcome); + complete["data"]["failure_code"] = format!("preview_{outcome}").into(); + } + let temp = tempdir().unwrap(); + let mut replies = if timeout { + vec![IntentReply::json(202, queued.clone())] + } else { + vec![ + IntentReply::json(202, queued.clone()), + IntentReply::json(200, complete.clone()), + ] + }; + if outcome == "unavailable" { + replies.insert( + 1, + IntentReply::json( + 503, + serde_json::json!({"type":"unavailable","detail":"retry this read"}), + ), + ); + } + let api = lifecycle_fixture(replies); + write_managed_target(temp.path(), &api.origin); + let output = managed_cli(temp.path(), &api.origin) + .args([ + verb, + selector, + selected, + "--timeout", + if timeout { "1" } else { "10" }, + "--idempotency-key", + "poll-key", + "--json", + ]) + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(if timeout { + 5 + } else if terminal_failure { + 1 + } else { + 0 + }), + "{output:?}" + ); + let mut expected = if timeout { + queued.clone() + } else { + complete.clone() + }; + if timeout && verb == "plan" { + expected["idempotency_key"] = "poll-key".into(); + } + assert_eq!(parse_stdout_json(&output), expected); + if timeout { + assert!( + String::from_utf8_lossy(&output.stderr).contains(if verb == "plan" { + "--idempotency-key KEY --target NAME" + } else { + "cluster status delivery-one --target NAME" + }) + ); + } + if terminal_failure { + assert!( + String::from_utf8_lossy(&output.stderr).contains(&format!("preview_{outcome}")) + ); + } assert_control_request( - &api.workflow_requests()[1], - "GET", - "/v1/clusters/managed-test/deployments/delivery-one", - Value::Null, - None, + &api.workflow_requests()[0], + "POST", + path, + request.clone(), + Some("poll-key"), ); + for observation in api.workflow_requests().iter().skip(1) { + assert_control_request(observation, "GET", lookup, Value::Null, None); + } + api.assert_complete(); + assert_no_core_effects(temp.path()); } - api.assert_complete(); - assert_no_core_effects(temp.path()); } } diff --git a/docs/user/cli/managed-lifecycle.md b/docs/user/cli/managed-lifecycle.md index cc045e8d7..6141860b6 100644 --- a/docs/user/cli/managed-lifecycle.md +++ b/docs/user/cli/managed-lifecycle.md @@ -125,6 +125,17 @@ omnigraph cluster plan --target production --rev NEW_COMMIT --json omnigraph cluster apply --target production --plan PREVIEW_ID --json ``` +`plan` waits for the accepted preview by reading its original ID. `--no-wait` +returns the accepted record immediately; `--timeout SECONDS` bounds only the +local wait (300 seconds by default). A timeout or disconnect does not cancel +the preview or submit another one. Progress goes to stderr; stdout contains +the final response, including the native plan when ready. + +To resume waiting, repeat `plan` with the same `--rev` and +`--idempotency-key` printed by the first invocation. The original preview's +deadline does not change. A `failed` or `expired` preview requires a fresh +plan with a new key; its `failure_code` explains the terminal outcome. + `push` uploads only `cluster.yaml`, referenced schemas, stored queries and policy files. Query-directory declarations discover immediate `.gq` files; they do not recurse. Unrelated files, folder context and credentials are not