diff --git a/Cargo.lock b/Cargo.lock index 1a5bd03..c3f09de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2499,6 +2499,7 @@ dependencies = [ "tar", "tempfile", "tokio", + "tokio-util", "tracing", "url", "wat", diff --git a/crates/imcp2-deploy/Cargo.toml b/crates/imcp2-deploy/Cargo.toml index fe5bfc3..18dd54a 100644 --- a/crates/imcp2-deploy/Cargo.toml +++ b/crates/imcp2-deploy/Cargo.toml @@ -23,6 +23,8 @@ candid = { workspace = true } # `rt-multi-thread`: the sync-plugin runtime runs a wasm component on a # blocking thread (`block_in_place`), which only a multi-thread runtime has. tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "fs", "io-util"] } +# The token a running deploy is stopped through. +tokio-util = "0.7" serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs index 564ee55..e54f048 100644 --- a/crates/imcp2-deploy/src/deploy.rs +++ b/crates/imcp2-deploy/src/deploy.rs @@ -31,6 +31,9 @@ use icp_project::{ prelude::{IC_MAINNET_NETWORK_API_URL, IC_ROOT_KEY}, }; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + use crate::{calls::AgentCalls, host}; /// Where the bundle bytes come from. @@ -85,6 +88,21 @@ pub struct DeployRequest { pub workdir: Option, } +/// How a running deploy is watched: where its progress goes as it happens, +/// and what tells it to stop. +#[derive(Clone, Debug, Default)] +pub struct Watch { + /// Cancelled to interrupt the deploy. The run stops at its next step + /// (a canister call already in flight completes on the network), and + /// the outcome reports [`Status::Interrupted`] with everything that + /// landed before the stop. + pub stop: CancellationToken, + /// Receives each log line as the run produces it, before the outcome + /// collects them all; `None` keeps them for the outcome alone. A + /// receiver that has gone away is not an error. + pub lines: Option>, +} + /// How a deploy ended. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Status { @@ -95,6 +113,9 @@ pub enum Status { Failed, /// The environment lists no canisters, so there was nothing to do. NothingToDeploy, + /// The run was stopped through [`Watch::stop`] before it finished; + /// `created` and `canister_ids` hold whatever landed before the stop. + Interrupted, } impl Status { @@ -103,6 +124,7 @@ impl Status { Status::Deployed => "deployed", Status::Failed => "failed", Status::NothingToDeploy => "nothing_to_deploy", + Status::Interrupted => "interrupted", } } } @@ -157,15 +179,16 @@ pub fn error_chain(error: &(dyn std::error::Error + 'static)) -> String { text } -/// Run one deploy as `identity`. +/// Run one deploy as `identity`, reporting to and stopped by `watch`. /// /// `Err` means the request never got as far as a deploy (an unreadable /// bundle, an environment the manifest does not declare, a canister name the /// id mapping has that the environment does not); `Ok` carries the outcome of -/// one that ran, failed or not. +/// one that ran — finished, failed or interrupted. pub async fn deploy_bundle( identity: Arc, request: DeployRequest, + watch: Watch, ) -> Result { if !INSTALL_MODES.contains(&request.mode.as_str()) { return Err(RequestError(format!( @@ -303,23 +326,42 @@ pub async fn deploy_bundle( // One reporter for the whole deploy; its events are turned into log lines // as they arrive, so a run that fails still reports every phase before it. let (reporter, receiver) = icp_events::channel::(); - let collector = tokio::spawn(collect_log(receiver)); + let collector = tokio::spawn(collect_log(receiver, watch.lines)); // From here the directory outlives this future (see above). let workdir = scratch.keep(); let mut report = DeployReport::default(); - let result = deploy(&host, &calls, ¶ms, &reporter, &mut report).await; + // The run is dropped the moment the stop is asked for: it ends at its + // next await, which is the next canister call or plugin step. `biased` + // so a stop asked for before the run begins is honoured without a step. + let result = { + let run = deploy(&host, &calls, ¶ms, &reporter, &mut report); + tokio::pin!(run); + tokio::select! { + biased; + _ = watch.stop.cancelled() => None, + result = &mut run => Some(result), + } + }; // The receiver ends only once every reporter handle is gone. drop(reporter); outcome.log = collector.await.unwrap_or_default(); outcome.created = report.created; match result { - Ok(()) => outcome.status = Status::Deployed, - Err(e) => { + Some(Ok(())) => outcome.status = Status::Deployed, + Some(Err(e)) => { outcome.status = Status::Failed; outcome.error = Some(error_chain(&e)); } + None => { + outcome.status = Status::Interrupted; + outcome.error = Some( + "the deploy was interrupted before it finished (the call was cancelled or \ + the client went away); `canister_ids` holds every canister it had created" + .into(), + ); + } } // Whatever the store holds now: the seeded ids plus every id created before @@ -339,9 +381,17 @@ pub async fn deploy_bundle( } // The outcome carries everything the directory recorded; the directory - // has done its job. + // has done its job. Not after an interruption: the one who asked for it + // may be a client that went away and will never read this outcome, so + // the directory stays as the record, as it does for a run that is cut + // off (see [`DeployRequest::workdir`]). drop(host); - if let Err(e) = tokio::fs::remove_dir_all(&workdir).await { + if outcome.status == Status::Interrupted { + outcome.log.push(format!( + "interrupted; the deploy directory '{}' is kept, with the ids it recorded", + workdir.display() + )); + } else if let Err(e) = tokio::fs::remove_dir_all(&workdir).await { outcome .log .push(format!("removing the deploy directory '{}' failed: {e}", workdir.display())); @@ -410,11 +460,19 @@ async fn read_bundle_file(path: &Utf8Path, limit: usize) -> Result, Requ /// `: ok` / `failed: ` / `skipped: `, with a task nested under /// a phase indented beneath it. Output lines are not kept — a sync plugin's /// retained stderr is the exception, since the operation asked for it to -/// survive — so the log stays a summary, not a transcript. +/// survive — so the log stays a summary, not a transcript. Each line also +/// goes to `lines` the moment it is made, for a watcher that reads along. async fn collect_log( mut receiver: tokio::sync::mpsc::UnboundedReceiver>, + lines: Option>, ) -> Vec { let mut log = Vec::new(); + let mut push = |line: String| { + if let Some(lines) = &lines { + let _ = lines.send(line.clone()); + } + log.push(line); + }; let mut started: HashMap = HashMap::new(); while let Some(event) = receiver.recv().await { match event.kind { @@ -428,9 +486,9 @@ async fn collect_log( let indent = if nested { " " } else { "" }; match outcome { TaskOutcome::Succeeded { retained_output } => { - log.push(format!("{indent}{label}: ok")); + push(format!("{indent}{label}: ok")); for line in retained_output { - log.push(format!("{indent} {line}")); + push(format!("{indent} {line}")); } } TaskOutcome::Failed { message, causes } => { @@ -439,10 +497,10 @@ async fn collect_log( line.push_str(": "); line.push_str(&cause); } - log.push(line); + push(line); } TaskOutcome::Skipped { reason } => { - log.push(format!("{indent}{label}: skipped: {reason}")); + push(format!("{indent}{label}: skipped: {reason}")); } } } @@ -571,10 +629,13 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn a_bundle_deploys_up_to_the_create_phase_offline() { let (wasm, sha) = wasm(); - let outcome = - deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&sha, &wasm), "ic")) - .await - .expect("the request is valid"); + let outcome = deploy_bundle( + Arc::new(AnonymousIdentity), + request(bundle(&sha, &wasm), "ic"), + Watch::default(), + ) + .await + .expect("the request is valid"); assert_eq!(outcome.status, Status::Failed); assert_eq!(outcome.network, "ic"); assert_eq!(outcome.canisters, ["backend"]); @@ -593,9 +654,13 @@ mod tests { async fn a_wasm_that_does_not_match_its_checksum_is_refused_up_front() { let (wasm, _) = wasm(); let wrong = "0".repeat(64); - let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&wrong, &wasm), "ic")) - .await - .expect_err("a checksum mismatch is refused"); + let err = deploy_bundle( + Arc::new(AnonymousIdentity), + request(bundle(&wrong, &wasm), "ic"), + Watch::default(), + ) + .await + .expect_err("a checksum mismatch is refused"); assert!(err.0.contains("backend") && err.0.contains("checksum mismatch"), "{err}"); assert!(err.0.contains(&wrong), "{err}"); } @@ -608,7 +673,7 @@ mod tests { let (wasm, sha) = wasm(); let mut local = request(bundle(&sha, &wasm), "local"); local.no_create = false; - let err = deploy_bundle(Arc::new(AnonymousIdentity), local) + let err = deploy_bundle(Arc::new(AnonymousIdentity), local, Watch::default()) .await .expect_err("a managed network is refused"); assert!(err.0.contains("not the Internet Computer") && err.0.contains("`ic`"), "{err}"); @@ -621,9 +686,10 @@ mod tests { ); let bytes = archive(&[("icp.yaml", manifest.as_bytes()), ("canisters/backend.wasm", &wasm)]); - let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "staging")) - .await - .expect_err("a testnet is refused"); + let err = + deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "staging"), Watch::default()) + .await + .expect_err("a testnet is refused"); assert!(err.0.contains("testnet") && err.0.contains("not the Internet Computer"), "{err}"); } @@ -633,15 +699,18 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn unknown_environments_and_canisters_are_request_errors() { let (wasm, sha) = wasm(); - let err = - deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&sha, &wasm), "staging")) - .await - .expect_err("no such environment"); + let err = deploy_bundle( + Arc::new(AnonymousIdentity), + request(bundle(&sha, &wasm), "staging"), + Watch::default(), + ) + .await + .expect_err("no such environment"); assert!(err.0.contains("staging"), "{err}"); let mut request = request(bundle(&sha, &wasm), "ic"); request.canister_ids.insert("frontend".into(), Principal::management_canister()); - let err = deploy_bundle(Arc::new(AnonymousIdentity), request) + let err = deploy_bundle(Arc::new(AnonymousIdentity), request, Watch::default()) .await .expect_err("no such canister"); assert!(err.0.contains("frontend"), "{err}"); @@ -656,9 +725,10 @@ mod tests { let manifest = "canisters:\n - name: backend\n build:\n steps:\n \ - type: script\n command: touch /tmp/pwned\n"; let bytes = archive(&[("icp.yaml", manifest.as_bytes())]); - let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) - .await - .expect_err("a script step is refused"); + let err = + deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic"), Watch::default()) + .await + .expect_err("a script step is refused"); assert!( err.0.contains("canister 'backend' has a build step that is not a pre-built module") && err.0.contains("touch /tmp/pwned"), @@ -683,9 +753,10 @@ mod tests { ("etc/canister.yaml", canister.as_bytes()), ("etc/backend.wasm", &wasm), ]); - let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) - .await - .expect_err("a canister directory outside the root is refused"); + let err = + deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic"), Watch::default()) + .await + .expect_err("a canister directory outside the root is refused"); // The confined filesystem finds nothing at `/etc`, so the loader does // not find the canister: the spelling itself is the refusal. assert!(err.0.contains("canister manifest") && err.0.contains("'/etc'"), "{err}"); @@ -705,9 +776,13 @@ mod tests { ); let bytes = archive(&[("icp.yaml", manifest.as_bytes()), ("canisters/backend.wasm", &wasm)]); - let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "elsewhere")) - .await - .expect_err("a network that is not `ic` is refused"); + let err = deploy_bundle( + Arc::new(AnonymousIdentity), + request(bytes, "elsewhere"), + Watch::default(), + ) + .await + .expect_err("a network that is not `ic` is refused"); assert!(err.0.contains("mainnet2") && err.0.contains("not the Internet Computer"), "{err}"); } @@ -721,9 +796,13 @@ mod tests { encoder.write_all(&vec![0u8; host::MAX_WASM_BYTES as usize + 1]).unwrap(); let gz = encoder.finish().unwrap(); let sha = hex::encode(Sha256::digest(&gz)); - let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&sha, &gz), "ic")) - .await - .expect_err("an over-large gzipped module is refused"); + let err = deploy_bundle( + Arc::new(AnonymousIdentity), + request(bundle(&sha, &gz), "ic"), + Watch::default(), + ) + .await + .expect_err("an over-large gzipped module is refused"); assert!(err.0.contains("backend") && err.0.contains("expands past"), "{err}"); } @@ -740,9 +819,10 @@ mod tests { ); let bytes = archive(&[("icp.yaml", manifest.as_bytes()), ("canisters/backend.wasm", &wasm)]); - let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) - .await - .expect_err("case-colliding names are refused"); + let err = + deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic"), Watch::default()) + .await + .expect_err("case-colliding names are refused"); assert!(err.0.contains("differ only in letter case"), "{err}"); } @@ -759,7 +839,8 @@ mod tests { let workdir = Utf8PathBuf::from_path_buf(parent.path().join("deploys")).unwrap(); let mut request = request(bundle(&sha, &wasm), "ic"); request.workdir = Some(workdir.clone()); - let outcome = deploy_bundle(Arc::new(AnonymousIdentity), request).await.unwrap(); + let outcome = + deploy_bundle(Arc::new(AnonymousIdentity), request, Watch::default()).await.unwrap(); assert_eq!(outcome.status, Status::Failed, "{:?}", outcome.log); assert!(workdir.is_dir(), "the parent is made"); let left: Vec<_> = std::fs::read_dir(&workdir).unwrap().collect(); @@ -773,7 +854,8 @@ mod tests { async fn an_unknown_mode_is_refused_at_entry() { let mut bad = request(Vec::new(), "ic"); bad.mode = "yolo".into(); - let err = deploy_bundle(Arc::new(AnonymousIdentity), bad).await.unwrap_err(); + let err = + deploy_bundle(Arc::new(AnonymousIdentity), bad, Watch::default()).await.unwrap_err(); assert!(err.0.contains("mode must be one of"), "{}", err.0); assert!(err.0.contains("'yolo'"), "{}", err.0); } @@ -809,9 +891,10 @@ mod tests { ); let bytes = archive(&[("icp.yaml", manifest.as_bytes()), ("canisters/backend.wasm", &wasm)]); - let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) - .await - .expect_err("a deeply nested init_args value is refused"); + let err = + deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic"), Watch::default()) + .await + .expect_err("a deeply nested init_args value is refused"); assert!(err.0.contains("`init_args` is nested too deeply"), "{err}"); let interface = format!("service : {{ f : ({}null) -> () }}", "opt ".repeat(200)); @@ -821,9 +904,10 @@ mod tests { .unwrap(); let sha = hex::encode(Sha256::digest(&wasm)); let bytes = bundle(&sha, &wasm); - let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) - .await - .expect_err("a deeply nested interface is refused"); + let err = + deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic"), Watch::default()) + .await + .expect_err("a deeply nested interface is refused"); assert!(err.0.contains("`candid:service` interface"), "{err}"); assert!(err.0.contains("nested too deeply"), "{err}"); @@ -833,10 +917,13 @@ mod tests { ) .unwrap(); let sha = hex::encode(Sha256::digest(&wasm)); - let outcome = - deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&sha, &wasm), "ic")) - .await - .unwrap(); + let outcome = deploy_bundle( + Arc::new(AnonymousIdentity), + request(bundle(&sha, &wasm), "ic"), + Watch::default(), + ) + .await + .unwrap(); assert!(outcome.log.iter().any(|l| l == "Building canisters: ok"), "{:?}", outcome.log); } @@ -846,7 +933,8 @@ mod tests { let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); let mut local = request(Vec::new(), "ic"); local.bundle = BundleSource::Path(path); - let err = deploy_bundle(Arc::new(AnonymousIdentity), local).await.unwrap_err(); + let err = + deploy_bundle(Arc::new(AnonymousIdentity), local, Watch::default()).await.unwrap_err(); assert!(err.0.contains("not a regular file"), "{}", err.0); } @@ -867,9 +955,10 @@ mod tests { ); let bytes = archive(&[("icp.yaml", manifest.as_bytes()), ("canisters/backend.wasm", &wasm)]); - let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) - .await - .expect_err("a variable file outside the bundle is refused"); + let err = + deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic"), Watch::default()) + .await + .expect_err("a variable file outside the bundle is refused"); assert!(err.0.contains("SECRET"), "{err}"); assert!(err.0.contains("'/etc/hostname' is outside"), "{err}"); @@ -879,9 +968,10 @@ mod tests { path: {path}\n sha256: {sha}\n" ); let bytes = archive(&[("icp.yaml", manifest.as_bytes())]); - let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) - .await - .expect_err("a wasm outside the bundle is refused"); + let err = + deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic"), Watch::default()) + .await + .expect_err("a wasm outside the bundle is refused"); assert!( err.0.contains("etc/hostname") && err.0.contains("not a regular file in the project"), @@ -896,7 +986,7 @@ mod tests { #[tokio::test] async fn the_event_stream_becomes_a_readable_log() { let (reporter, receiver) = icp_events::channel::(); - let collector = tokio::spawn(collect_log(receiver)); + let collector = tokio::spawn(collect_log(receiver, None)); let phase = reporter.task(Task::phase("Creating canisters:")); let create = phase.reporter().task(Task::create("backend")); create.finish(TaskOutcome::succeeded()); @@ -922,6 +1012,50 @@ mod tests { ); } + // A watcher reads each line as the run makes it — the same lines, in the + // same order, the outcome collects at the end. + #[tokio::test(flavor = "multi_thread")] + async fn the_log_is_streamed_as_it_is_made() { + let (wasm, sha) = wasm(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let watch = Watch { stop: CancellationToken::new(), lines: Some(tx) }; + let outcome = + deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&sha, &wasm), "ic"), watch) + .await + .unwrap(); + assert_eq!(outcome.status, Status::Failed); + let mut streamed = Vec::new(); + while let Some(line) = rx.recv().await { + streamed.push(line); + } + assert_eq!(streamed, outcome.log); + assert!(streamed.iter().any(|l| l == "Building canisters: ok"), "{streamed:?}"); + } + + // A stop asked for before the run begins ends it without a step, and + // the directory stays behind as the record, since whoever asked may + // never read the answer. + #[tokio::test(flavor = "multi_thread")] + async fn an_interrupted_deploy_stops_and_keeps_its_directory() { + let (wasm, sha) = wasm(); + let parent = tempfile::tempdir().unwrap(); + let mut request = request(bundle(&sha, &wasm), "ic"); + request.workdir = Some(Utf8PathBuf::from_path_buf(parent.path().to_path_buf()).unwrap()); + let stop = CancellationToken::new(); + stop.cancel(); + let watch = Watch { stop, lines: None }; + let outcome = deploy_bundle(Arc::new(AnonymousIdentity), request, watch).await.unwrap(); + assert_eq!(outcome.status, Status::Interrupted); + assert_eq!(outcome.canisters, ["backend"]); + assert!(outcome.created.is_empty()); + let error = outcome.error.as_deref().unwrap(); + assert!(error.contains("interrupted"), "{error}"); + assert!(!outcome.log.iter().any(|l| l.starts_with("Building")), "{:?}", outcome.log); + let kept: Vec<_> = std::fs::read_dir(parent.path()).unwrap().flatten().collect(); + assert_eq!(kept.len(), 1, "the deploy directory is kept"); + assert!(outcome.log.last().unwrap().contains("is kept"), "{:?}", outcome.log); + } + #[test] fn error_chains_read_outermost_first() { #[derive(Debug)] diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index c01c186..206a990 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -51,11 +51,13 @@ use camino::Utf8PathBuf; use candid::Principal; use ic_agent::{identity::DelegatedIdentity, Identity}; use imcp2_core::{cycles_balance_of, identities::Identities, CyclesBalance, SessionResolver}; +#[allow(deprecated)] +use rmcp::model::{LoggingLevel, LoggingMessageNotificationParam}; use rmcp::{ handler::server::wrapper::Parameters, - model::{CallToolResult, ContentBlock}, + model::{CallToolResult, ContentBlock, ProgressNotificationParam, ProgressToken}, schemars, - service::RequestContext, + service::{Peer, RequestContext}, tool, tool_router, ErrorData as McpError, RoleServer, }; use serde::{Deserialize, Serialize}; @@ -65,7 +67,9 @@ mod deploy; mod host; mod identity; -pub use deploy::{deploy_bundle, BundleSource, DeployOutcome, DeployRequest, RequestError, Status}; +pub use deploy::{ + deploy_bundle, BundleSource, DeployOutcome, DeployRequest, RequestError, Status, Watch, +}; use identity::RefreshingIdentity; /// The icp CLI's default funding for a canister it creates (`icp deploy @@ -152,8 +156,9 @@ pub struct CreatedCanister { #[derive(Debug, Serialize, schemars::JsonSchema)] pub struct DeployBundleOutput { /// `deployed`, `failed` (see `error`; `created` and `canister_ids` hold - /// what landed before the failure), or `nothing_to_deploy` (the environment - /// lists no canisters). + /// what landed before the failure), `interrupted` (the call was cancelled + /// or the client went away; likewise), or `nothing_to_deploy` (the + /// environment lists no canisters). pub status: String, /// The environment deployed. pub environment: String, @@ -210,6 +215,12 @@ impl DeployBundleOutput { "Environment '{}' declares no canisters, so there was nothing to deploy.", self.environment ), + "interrupted" => format!( + "Deploying environment '{}' to network '{}' was INTERRUPTED: {}", + self.environment, + self.network, + self.error.as_deref().unwrap_or("stopped before it finished") + ), _ => format!( "Deploying environment '{}' to network '{}' FAILED: {}", self.environment, @@ -379,7 +390,7 @@ impl IcDeployTools { } #[tool( - description = "Deploy an icp project bundle (the .tar.gz that `icp project bundle` writes) to the Internet Computer as the user, running every phase of `icp deploy`: create the canisters the bundle names that do not exist yet (funded from the user's cycles balance), wire their ids into each other, apply settings, install or upgrade the code, and run the sync steps (a frontend's asset upload). Pass the bundle by absolute path or base64, the environment name it was built for (usually \"ic\"), and, to upgrade rather than create, the canister_ids a previous deploy returned. Returns the status, the resulting canister-id mapping, and a log of each phase; on failure the mapping still holds every canister that was created.", + description = "Deploy an icp project bundle (the .tar.gz that `icp project bundle` writes) to the Internet Computer as the user, running every phase of `icp deploy`: create the canisters the bundle names that do not exist yet (funded from the user's cycles balance), wire their ids into each other, apply settings, install or upgrade the code, and run the sync steps (a frontend's asset upload). Pass the bundle by absolute path or base64, the environment name it was built for (usually \"ic\"), and, to upgrade rather than create, the canister_ids a previous deploy returned. The call lasts as long as the deploy and reports each phase as it completes, as a progress notification (send a progressToken) and a log message; cancelling the call interrupts the deploy at its next step. Returns the status, the resulting canister-id mapping, and a log of each phase; on failure or interruption the mapping still holds every canister that was created.", annotations(title = "Deploy a project bundle", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -417,13 +428,23 @@ impl IcDeployTools { Ok(id) => id, Err(e) => return Ok(err(e)), }; - match deploy_bundle(identity, request).await { + // Each phase reaches the client as it completes, and the client's + // cancellation of the request stops the deploy. + let (lines, receiver) = tokio::sync::mpsc::unbounded_channel(); + let forwarder = + tokio::spawn(forward_lines(ctx.peer.clone(), ctx.meta.get_progress_token(), receiver)); + let watch = Watch { stop: ctx.ct.child_token(), lines: Some(lines) }; + let result = deploy_bundle(identity, request, watch).await; + // The sender went with the run; the forwarder ends once it has + // delivered what is left. + let _ = forwarder.await; + match result { Err(RequestError(e)) => Ok(err(e)), Ok(outcome) => { let output = DeployBundleOutput::from_outcome(&outcome); let text = output.human(); Ok(match outcome.status { - Status::Failed => err_structured(text, &output), + Status::Failed | Status::Interrupted => err_structured(text, &output), Status::Deployed | Status::NothingToDeploy => ok_structured(text, &output), }) } @@ -477,6 +498,41 @@ impl IcDeployTools { } } +/// Deliver each log line of a running deploy to the client as it is made: as +/// a progress notification when the request carried a progress token (which +/// is also what keeps a client's per-call timeout from running out during a +/// long deploy), else as a logging message, the one other thing a server may +/// say mid-call. A client that cannot be reached is not an error here; the +/// outcome carries every line regardless. +// MCP logging is deprecated (SEP-2577) but still delivered; it is the +// fallback for a client that sends no progress token, nothing more. +#[allow(deprecated)] +async fn forward_lines( + peer: Peer, + progress_token: Option, + mut lines: tokio::sync::mpsc::UnboundedReceiver, +) { + let mut progress = 0.0; + while let Some(line) = lines.recv().await { + progress += 1.0; + let _ = match &progress_token { + Some(token) => { + let param = + ProgressNotificationParam::new(token.clone(), progress).with_message(line); + peer.notify_progress(param).await + } + None => { + let param = LoggingMessageNotificationParam::new( + LoggingLevel::Info, + serde_json::Value::String(line), + ) + .with_logger("icp_deploy_bundle"); + peer.notify_logging_message(param).await + } + }; + } +} + /// `icp_cycles_balance` and `icp_deploy_guide` take no arguments. #[derive(Debug, Default, Deserialize, schemars::JsonSchema)] pub struct NoArgs {} diff --git a/crates/imcp2-deploy/static/skills/deploy-with-mcp.md b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md index 2e8b33c..ef1fb9d 100644 --- a/crates/imcp2-deploy/static/skills/deploy-with-mcp.md +++ b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md @@ -128,22 +128,33 @@ replaced. Pass `skip_candid_check: true` only when the user has accepted the break. `mode` (default `auto`; `install`, `reinstall`, `upgrade`), `cycles`, `subnet` and `no_create` mirror the flags of `icp deploy`. +### Watch it run + +The call lasts as long as the deploy: creating canisters, installing code +and syncing a frontend's assets take from seconds to minutes. The tool +reports each phase and canister step the moment it completes, as a progress +notification when your call carries a `progressToken` (most clients attach +one; it is also what keeps your client's per-call timeout from expiring +mid-deploy), else as a server log message. Keep the call open until it +returns. Cancelling it interrupts the deploy at its next step, see pitfall 8. + ### Read the answer The tool returns: -- `status`: `deployed`, `failed`, or `nothing_to_deploy`. +- `status`: `deployed`, `failed`, `interrupted`, or `nothing_to_deploy`. - `canister_ids`: the mapping after this run, the ids you passed in plus every id created. Step 3 uses this. - `created`: the canisters created by this run, in order. -- `error`: what failed, when the status is `failed`. +- `error`: what failed, when the status is `failed` or `interrupted`. - `log`: one line per phase and per canister task, with the cause of each failure. -A deploy that fails still returns every canister it created before failing, -in both `canister_ids` and `created`. Do step 3 on that answer too, then -fix the cause the log names and run step 2 again with the updated mapping: -the retry upgrades what exists and creates only what is still missing. +A deploy that fails, or is interrupted, still returns every canister it +created before that, in both `canister_ids` and `created`. Do step 3 on that +answer too, then fix the cause the log names and run step 2 again with the +updated mapping: the retry upgrades what exists and creates only what is +still missing. ## Step 3: Update the project's mapping file @@ -187,9 +198,12 @@ interchangeably for that environment. 7. **Deploying twice at once.** One deploy runs at a time on this server; a second call while one runs is refused, not queued. Wait for the answer, do step 3, then deploy again from the updated mapping. -8. **A call that never returned.** If the server stopped mid-deploy or the - call was cancelled, the canisters it created are not lost: the deploy's - working directory is kept under the user's local data directory +8. **A call that never returned.** Cancelling the call, or losing the + connection, interrupts the deploy at its next step; a canister call + already in flight completes on the network. If the server stopped + mid-deploy, or you cancelled and never read the answer, the canisters it + created are not lost: the deploy's working directory is kept under the + user's local data directory (`~/.local/share/imcp2-local/deploys/` on Linux, `~/Library/Application Support/imcp2-local/deploys/` on macOS, `%LOCALAPPDATA%\imcp2-local\deploys\` on Windows), and its diff --git a/crates/imcp2-local/README.md b/crates/imcp2-local/README.md index 4c7e243..6ab7b76 100644 --- a/crates/imcp2-local/README.md +++ b/crates/imcp2-local/README.md @@ -163,10 +163,15 @@ It takes: previous call returned). Canisters named there are upgraded in place; the rest are created. -It answers with the status (`deployed`, `failed`, `nothing_to_deploy`), the -resulting canister-id mapping — pass it back next time to upgrade — the -canisters created, and a log of each phase. A failed deploy still reports every -canister it created before failing, so the run can be picked up rather than +The call lasts as long as the deploy, and reports each phase and canister +step as it completes: as an MCP progress notification when the client's call +carries a progress token (which also keeps the client's per-call timeout from +expiring mid-deploy), else as a server log message. Cancelling the call +interrupts the deploy at its next step. It answers with the status +(`deployed`, `failed`, `interrupted`, `nothing_to_deploy`), the resulting +canister-id mapping — pass it back next time to upgrade — the canisters +created, and a log of each phase. A failed or interrupted deploy still reports +every canister it created before that, so the run can be picked up rather than repeated. Calls are signed as your **default Internet Identity account at @@ -191,7 +196,7 @@ data directory — `~/.local/share/imcp2-local/deploys/` on Linux, `~/Library/Application Support/imcp2-local/deploys/` on macOS, `%LOCALAPPDATA%\imcp2-local\deploys\` on Windows — removed once the deploy has returned its answer. A deploy that never returns (the server stopped -mid-way, the call cancelled) leaves its directory there, and inside it +mid-way) or is interrupted leaves its directory there, and inside it `.icp/data/mappings/.ids.json` records every canister the deploy had created: merge it into the project's mapping before deploying again, then delete the directory. Nothing is written to the project the diff --git a/crates/imcp2-local/src/server.rs b/crates/imcp2-local/src/server.rs index 6c53ad9..b9a7781 100644 --- a/crates/imcp2-local/src/server.rs +++ b/crates/imcp2-local/src/server.rs @@ -264,6 +264,10 @@ impl ServerHandler for LocalServer { let mut info = self.tools.get_info(); info.server_info.name = "imcp2-local".into(); info.server_info.version = env!("CARGO_PKG_VERSION").into(); + // A running deploy reports each phase mid-call: as progress when the + // request carried a token, else as a logging message, which a client + // only expects from a server that declares it. + info.capabilities.logging = Some(Default::default()); // The core instructions apply verbatim; add how signing in works here // (the hosted server logs in via OAuth outside the tool surface, so // core's text says nothing about it). @@ -284,8 +288,10 @@ impl ServerHandler for LocalServer { so the CLI's own only when the CLI's selected identity is that link; a local key \ or another account is a different principal, which `icp identity principal` \ shows — not as the management identity the canister tools act as. It needs an \ - \"Actions & questions\" session, runs one deploy at a time, and returns the \ - canister-id mapping to pass back next time. \ + \"Actions & questions\" session, runs one deploy at a time, lasts as long as the \ + deploy (send a progressToken to see each phase as it completes; cancelling the \ + call interrupts the deploy at its next step), and returns the canister-id \ + mapping to pass back next time. \ The `skill://deploy-with-mcp` resource walks through the loop: bundle, deploy, \ write the mapping back into the project; `icp_deploy_guide` returns the same \ text."