Skip to main content

vta_cli_common/commands/
services.rs

1//! `pnm services …` command implementations — unified CLI surface.
2//!
3//! Spec: `docs/05-design-notes/runtime-service-management.md` §5.1.
4//!
5//! Twelve commands across two transport kinds plus a top-level
6//! list/report. Each function calls the matching `vta-sdk` client
7//! method; the typed `VtaError` variants are surfaced via the
8//! existing CLI error renderer (`render::print_cli_error`) which
9//! attaches operator-actionable suggested-fix strings per
10//! CLAUDE.md.
11//!
12//! The retired `pnm mediator …` subcommand surface is replaced by
13//! `pnm services didcomm {update,rollback,drain {list,cancel}}` —
14//! see the migration cue in pnm-cli/cnm-cli for the
15//! retired-command UX.
16
17use vta_sdk::client::VtaClient;
18use vta_sdk::protocol::services::{
19    DisableRestRequest, EnableRestRequest, RollbackDidcommRequest, RollbackRestRequest,
20    UpdateRestRequest,
21};
22use vta_sdk::protocol::{DisableDidcommRequest, EnableDidcommRequest, UpdateDidcommRequest};
23
24// ── services list ──────────────────────────────────────────────────
25
26/// `pnm services list` — show current REST + DIDComm advertisements.
27pub async fn cmd_services_list(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
28    let response = client.list_services().await?;
29
30    println!("Services advertised on this VTA's DID document:");
31    println!();
32    for state in &response.services {
33        match state {
34            vta_sdk::protocol::services::ServiceState::Didcomm {
35                enabled,
36                mediator_did,
37                routing_keys,
38            } => {
39                let on = if *enabled { "on" } else { "off" };
40                println!("  DIDComm:  {on}");
41                if let Some(m) = mediator_did {
42                    println!("    Mediator:     {m}");
43                }
44                if !routing_keys.is_empty() {
45                    println!("    Routing keys: {}", routing_keys.join(", "));
46                }
47            }
48            vta_sdk::protocol::services::ServiceState::Rest { enabled, url } => {
49                let on = if *enabled { "on" } else { "off" };
50                println!("  REST:     {on}");
51                if let Some(u) = url {
52                    println!("    URL:          {u}");
53                }
54            }
55        }
56    }
57    Ok(())
58}
59
60// ── services rest {enable, update, disable, rollback} ─────────────
61
62pub async fn cmd_services_rest_enable(
63    client: &VtaClient,
64    url: String,
65) -> Result<(), Box<dyn std::error::Error>> {
66    let req = EnableRestRequest::new(url);
67    let resp = client.enable_rest(req).await?;
68    println!("REST enabled.");
69    println!("  New version ID: {}", resp.log_entry_version_id);
70    println!("  Effective at:   {}", resp.effective_at);
71    print_serverless_hint(resp.serverless, &resp.vta_did);
72    Ok(())
73}
74
75pub async fn cmd_services_rest_update(
76    client: &VtaClient,
77    url: String,
78) -> Result<(), Box<dyn std::error::Error>> {
79    let req = UpdateRestRequest::new(url);
80    let resp = client.update_rest(req).await?;
81    println!("REST URL updated.");
82    println!("  New version ID: {}", resp.log_entry_version_id);
83    println!("  Effective at:   {}", resp.effective_at);
84    print_serverless_hint(resp.serverless, &resp.vta_did);
85    Ok(())
86}
87
88pub async fn cmd_services_rest_disable(
89    client: &VtaClient,
90) -> Result<(), Box<dyn std::error::Error>> {
91    let resp = client.disable_rest(DisableRestRequest::default()).await?;
92    println!("REST disabled.");
93    println!("  New version ID: {}", resp.log_entry_version_id);
94    println!("  Effective at:   {}", resp.effective_at);
95    print_serverless_hint(resp.serverless, &resp.vta_did);
96    Ok(())
97}
98
99pub async fn cmd_services_rest_rollback(
100    client: &VtaClient,
101) -> Result<(), Box<dyn std::error::Error>> {
102    let resp = client.rollback_rest(RollbackRestRequest::default()).await?;
103    print_rollback_result("REST", &resp);
104    Ok(())
105}
106
107// ── services didcomm {enable, update, disable, rollback} ──────────
108
109pub async fn cmd_services_didcomm_enable(
110    client: &VtaClient,
111    mediator_did: String,
112    force: bool,
113    handshake_timeout_secs: Option<u64>,
114) -> Result<(), Box<dyn std::error::Error>> {
115    let mut req = EnableDidcommRequest::new(&mediator_did);
116    req.force = force;
117    req.handshake_timeout_secs = handshake_timeout_secs;
118    let resp = client.enable_didcomm(req).await?;
119    println!("DIDComm enabled.");
120    println!("  Mediator DID:   {}", resp.mediator_did);
121    if !resp.mediator_endpoint.is_empty() {
122        println!("  Mediator URL:   {}", resp.mediator_endpoint);
123    }
124    println!("  New version ID: {}", resp.new_version_id);
125    if force {
126        println!();
127        println!("  Note: --force was set; mediator handshake steps 2-5 were bypassed.");
128    }
129    print_serverless_hint(resp.serverless, &resp.vta_did);
130    Ok(())
131}
132
133pub async fn cmd_services_didcomm_update(
134    client: &VtaClient,
135    new_mediator_did: String,
136    drain_ttl_secs: u64,
137    force: bool,
138    handshake_timeout_secs: Option<u64>,
139) -> Result<(), Box<dyn std::error::Error>> {
140    let mut req = UpdateDidcommRequest::new(&new_mediator_did, drain_ttl_secs);
141    req.force = force;
142    req.handshake_timeout_secs = handshake_timeout_secs;
143    let resp = client.update_didcomm(req).await?;
144    println!("DIDComm mediator updated.");
145    println!("  Prior mediator:  {}", resp.prior_mediator_did);
146    println!("  Active mediator: {}", resp.active_mediator_did);
147    if !resp.active_mediator_endpoint.is_empty() {
148        println!("  Active endpoint: {}", resp.active_mediator_endpoint);
149    }
150    println!("  New version ID:  {}", resp.new_version_id);
151    println!(
152        "  Drain deadline:  {} (prior listener stays up until then)",
153        resp.drains_until
154    );
155    print_serverless_hint(resp.serverless, &resp.vta_did);
156    Ok(())
157}
158
159pub async fn cmd_services_didcomm_disable(
160    client: &VtaClient,
161    drain_ttl_secs: u64,
162) -> Result<(), Box<dyn std::error::Error>> {
163    let req = DisableDidcommRequest::new(drain_ttl_secs);
164    let resp = client.disable_didcomm(req).await?;
165    println!("DIDComm disabled.");
166    println!("  Prior mediator: {}", resp.prior_mediator_did);
167    println!("  New version ID: {}", resp.new_version_id);
168    match resp.drains_until {
169        Some(deadline) => {
170            println!("  Drain deadline: {deadline}");
171            println!();
172            println!("  The listener stays up until the deadline so in-flight messages can drain.");
173            println!(
174                "  Cancel early with `pnm services didcomm drain cancel --mediator-did <did>`."
175            );
176        }
177        None => println!("  Listener torn down immediately (drain TTL was 0)."),
178    }
179    print_serverless_hint(resp.serverless, &resp.vta_did);
180    Ok(())
181}
182
183pub async fn cmd_services_didcomm_rollback(
184    client: &VtaClient,
185    drain_ttl_secs: Option<u64>,
186) -> Result<(), Box<dyn std::error::Error>> {
187    let req = RollbackDidcommRequest { drain_ttl_secs };
188    let resp = client.rollback_didcomm(req).await?;
189    print_rollback_result("DIDComm", &resp);
190    Ok(())
191}
192
193// ── services didcomm drain {list, cancel} ─────────────────────────
194
195pub async fn cmd_services_didcomm_drain_list(
196    client: &VtaClient,
197) -> Result<(), Box<dyn std::error::Error>> {
198    let resp = client.list_drain().await?;
199    if resp.entries.is_empty() {
200        println!("No mediators currently in drain.");
201        return Ok(());
202    }
203    println!("Drain set ({} mediator(s)):", resp.entries.len());
204    println!();
205    let header_did = "MEDIATOR DID";
206    let header_until = "DRAIN UNTIL";
207    println!("  {header_did:<60}  {header_until}");
208    for e in &resp.entries {
209        println!(
210            "  {:<60}  {}",
211            truncate(&e.mediator_did, 60),
212            e.drains_until
213        );
214    }
215    Ok(())
216}
217
218pub async fn cmd_services_didcomm_drain_cancel(
219    client: &VtaClient,
220    mediator_did: String,
221) -> Result<(), Box<dyn std::error::Error>> {
222    let req = vta_sdk::protocol::DrainCancelRequest { mediator_did };
223    let resp = client.drain_cancel(req).await?;
224    println!("Drain cancelled for {}.", resp.mediator_did);
225    println!("  Listener was torn down immediately.");
226    Ok(())
227}
228
229// ── services report ───────────────────────────────────────────────
230
231pub async fn cmd_services_report(
232    client: &VtaClient,
233    since: Option<String>,
234    until: Option<String>,
235    format: ReportFormat,
236) -> Result<(), Box<dyn std::error::Error>> {
237    let report = client
238        .mediator_report(since.as_deref(), until.as_deref())
239        .await?;
240
241    match format {
242        ReportFormat::Json => {
243            println!("{}", serde_json::to_string_pretty(&report)?);
244        }
245        ReportFormat::Table => {
246            println!("Service-management report");
247            if let Some(ref s) = report.since {
248                println!("  Window: {s} → {}", report.until);
249            } else {
250                println!("  Window: (all time) → {}", report.until);
251            }
252            println!();
253            if report.mediators.is_empty() {
254                println!("  No inbound DIDComm messages recorded.");
255            } else {
256                println!("  Per-mediator inbound counts (most recent first):");
257                let header_did = "MEDIATOR DID";
258                let header_count = "INBOUND";
259                println!("    {header_did:<60}  {header_count:>10}  LAST SEEN");
260                for m in &report.mediators {
261                    println!(
262                        "    {:<60}  {:>10}  {}",
263                        truncate(&m.mediator_did, 60),
264                        m.inbound_count,
265                        m.last_seen
266                    );
267                }
268            }
269            if !report.senders.is_empty() {
270                println!();
271                println!("  Senders by last-seen mediator:");
272                for s in &report.senders {
273                    println!(
274                        "    {} → {} (at {})",
275                        truncate(&s.sender_did, 50),
276                        truncate(&s.last_seen_mediator, 50),
277                        s.last_seen_at
278                    );
279                }
280            }
281        }
282    }
283    Ok(())
284}
285
286// ── shared helpers ────────────────────────────────────────────────
287
288fn print_rollback_result(kind: &str, resp: &vta_sdk::protocol::services::RollbackResponse) {
289    if resp.kind == "no_op" {
290        println!("{kind} rollback: no change required.");
291        println!("  Snapshot matches current state — nothing to do.");
292        return;
293    }
294    println!("{kind} rolled back.");
295    println!("  Action:         {}", resp.kind);
296    if !resp.log_entry_version_id.is_empty() {
297        println!("  New version ID: {}", resp.log_entry_version_id);
298    }
299    println!("  Effective at:   {}", resp.effective_at);
300    if let Some(ref drain_until) = resp.drain_until {
301        println!("  Drain deadline: {drain_until}");
302    }
303    if let Some(ref draining) = resp.draining_mediator {
304        println!("  Draining:       {draining}");
305    }
306    print_serverless_hint(resp.serverless, &resp.vta_did);
307}
308
309/// Print the "fetch did.jsonl + redeploy" hint when the mutation
310/// just wrote a LogEntry to a self-hosted VTA DID.
311///
312/// Silent when `serverless` is false (the VTA published to a host
313/// as part of the call — no follow-up needed) and when `vta_did`
314/// is empty (no LogEntry was written, e.g. no-op rollback).
315///
316/// Suffix is two operator-actionable lines: the command and the
317/// reason. Operators running scripted updates will see the line
318/// every time on serverless deployments — that's intentional,
319/// since the alternative is stale resolvers without an obvious
320/// cause.
321pub fn print_serverless_hint(serverless: bool, vta_did: &str) {
322    if !serverless || vta_did.is_empty() {
323        return;
324    }
325    println!();
326    println!("  This VTA's DID is self-hosted. Fetch the updated log:");
327    println!("    pnm webvh did-log {vta_did} --out did.jsonl");
328    println!("  then redeploy did.jsonl to your host. Until you do,");
329    println!("  resolvers will keep returning the prior version.");
330}
331
332#[derive(Debug, Clone, Copy)]
333pub enum ReportFormat {
334    Json,
335    Table,
336}
337
338impl std::str::FromStr for ReportFormat {
339    type Err = String;
340    fn from_str(s: &str) -> Result<Self, Self::Err> {
341        match s {
342            "json" => Ok(Self::Json),
343            "table" => Ok(Self::Table),
344            other => Err(format!("unknown format `{other}` — use `json` or `table`")),
345        }
346    }
347}
348
349fn truncate(s: &str, max: usize) -> String {
350    if s.chars().count() <= max {
351        s.to_string()
352    } else {
353        let mut out: String = s.chars().take(max - 1).collect();
354        out.push('…');
355        out
356    }
357}