From 32942c6884f3e02dc0e12b2a5ebdb85635d9fd7f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 15:57:47 +0300 Subject: [PATCH 1/3] feat(host): add deployment log retrieval Add a new `deployment_logs` method to the `Host` trait that returns build and runtime events for a deployment, along with the corresponding `DeploymentLog` type, Vercel provider implementation, RPC operation, and round-trip JSON tests. This enables users to inspect deployment output and errors without needing direct provider API access. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/host/mod.rs | 12 +++++++++-- src/host/test.rs | 14 ++++++++++++- src/host/types.rs | 17 +++++++++++++++ src/lib.rs | 4 ++-- src/providers/vercel/mod.rs | 25 +++++++++++++++++++--- src/providers/vercel/test.rs | 27 ++++++++++++++++++++++++ src/providers/vercel/wire.rs | 40 ++++++++++++++++++++++++++++++++++-- src/rpc/mod.rs | 15 ++++++++++++-- 8 files changed, 142 insertions(+), 12 deletions(-) diff --git a/src/host/mod.rs b/src/host/mod.rs index c13952c..53879b4 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -23,8 +23,8 @@ use async_trait::async_trait; use crate::Result; use crate::host::types::{ - AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, Deployment, Domain, - EnvVar, EnvVarRecord, Site, SiteSpec, + AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, Deployment, + DeploymentLog, Domain, EnvVar, EnvVarRecord, Site, SiteSpec, }; use crate::providers::ProviderKind; @@ -134,6 +134,14 @@ pub trait Host: Send + Sync + std::fmt::Debug { /// Returns a provider error. async fn list_deployments(&self, site: &str, limit: u32) -> Result>; + /// Lists the build and runtime events a deployment recorded, oldest first. + /// + /// # Errors + /// + /// Returns a provider error, including [`Error::NotFound`](crate::Error::NotFound) + /// for an unknown deployment identifier. + async fn deployment_logs(&self, id: &str) -> Result>; + /// Points the site's production traffic at an existing deployment. /// /// This is both the promote and the rollback: a rollback is a promote of an diff --git a/src/host/test.rs b/src/host/test.rs index e697380..2e470ac 100644 --- a/src/host/test.rs +++ b/src/host/test.rs @@ -6,7 +6,7 @@ use crate::Error; use crate::bundle::Bundle; use crate::host::types::{ AnalyticsDimension, AnalyticsQuery, DatabaseKind, DatabaseSpec, DeployRequest, Deployment, - DeploymentStatus, DeploymentTarget, EnvVar, Framework, SiteSpec, + DeploymentLog, DeploymentStatus, DeploymentTarget, EnvVar, Framework, SiteSpec, }; fn bundle() -> Bundle { @@ -247,6 +247,18 @@ fn a_deployment_round_trips_through_json() { ); } +#[test] +fn a_deployment_log_round_trips_through_json() { + let log = DeploymentLog { + created_at_ms: Some(1), + kind: "stderr".to_owned(), + message: "missing module".to_owned(), + }; + + let json = serde_json::to_string(&log).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), log); +} + #[test] fn a_status_this_crate_does_not_model_survives_a_round_trip() { let status = DeploymentStatus::Other("BLOCKED".to_owned()); diff --git a/src/host/types.rs b/src/host/types.rs index 9b8ce6a..a0dffbc 100644 --- a/src/host/types.rs +++ b/src/host/types.rs @@ -250,6 +250,23 @@ pub struct Deployment { pub error_message: Option, } +/// One build or runtime event a provider recorded for a deployment. +/// +/// Providers use different event names, so [`kind`](Self::kind) is preserved +/// rather than forced into a small enum. The message is the provider's +/// human-readable payload; it is not a request credential or environment +/// variable value. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeploymentLog { + /// When the provider recorded the event, in milliseconds since the Unix epoch. + #[serde(default)] + pub created_at_ms: Option, + /// The provider's event kind, such as `stdout`, `stderr`, or `error`. + pub kind: String, + /// The event's human-readable message. + pub message: String, +} + /// An environment variable to set on a site. /// /// The value is write-only across this API: it goes out in a request and is diff --git a/src/lib.rs b/src/lib.rs index 847d877..5c0a1ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,8 +66,8 @@ pub use error::{Error, Result}; pub use host::Host; pub use host::types::{ AnalyticsBucket, AnalyticsDimension, AnalyticsQuery, AnalyticsSummary, Database, DatabaseKind, - DatabaseSpec, DeployRequest, Deployment, DeploymentStatus, DeploymentTarget, Domain, EnvVar, - EnvVarRecord, Framework, Site, SiteSpec, + DatabaseSpec, DeployRequest, Deployment, DeploymentLog, DeploymentStatus, DeploymentTarget, + Domain, EnvVar, EnvVarRecord, Framework, Site, SiteSpec, }; pub use launch::launch; pub use launch::types::{Launch, LaunchPlan}; diff --git a/src/providers/vercel/mod.rs b/src/providers/vercel/mod.rs index 8a6f1de..81f1bbe 100644 --- a/src/providers/vercel/mod.rs +++ b/src/providers/vercel/mod.rs @@ -31,7 +31,8 @@ use serde_json::Value; use crate::host::Host; use crate::host::types::{ AnalyticsBucket, AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, - Deployment, DeploymentTarget, Domain, EnvVar, EnvVarRecord, Framework, Site, SiteSpec, + Deployment, DeploymentLog, DeploymentTarget, Domain, EnvVar, EnvVarRecord, Framework, Site, + SiteSpec, }; use crate::providers::ProviderKind; use crate::{Credentials, Error, Result}; @@ -39,8 +40,9 @@ use crate::{Credentials, Error, Result}; use self::http::{DEFAULT_BASE_URL, Http}; use self::wire::{ AnalyticsEnvelope, Configuration, ConnectResource, CreateDeployment, CreateDomain, - CreateEnvVar, CreateProject, CreateStore, DeploymentBody, Deployments, DomainBody, Domains, - Envs, Products, Project, ProjectSettings, Projects, StoreEnvelope, UploadedFile, + CreateEnvVar, CreateProject, CreateStore, DeploymentBody, DeploymentEvents, Deployments, + DomainBody, Domains, Envs, Products, Project, ProjectSettings, Projects, StoreEnvelope, + UploadedFile, }; mod http; @@ -428,6 +430,23 @@ impl Host for Vercel { .collect()) } + async fn deployment_logs(&self, id: &str) -> Result> { + let events: DeploymentEvents = self + .http + .get_json( + &format!("/v3/deployments/{}/events", encode_segment(id)), + &[], + "deployment events", + ) + .await?; + + Ok(events + .events + .into_iter() + .map(self::wire::DeploymentEvent::into_log) + .collect()) + } + async fn promote(&self, site: &str, deployment: &str) -> Result<()> { let project = self.project_id(site).await?; let builder = self.http.request( diff --git a/src/providers/vercel/test.rs b/src/providers/vercel/test.rs index 5ad0651..7a7de38 100644 --- a/src/providers/vercel/test.rs +++ b/src/providers/vercel/test.rs @@ -492,6 +492,33 @@ async fn an_empty_deployment_list_decodes() { ); } +#[tokio::test] +async fn deployment_events_preserve_their_kind_message_and_timestamp() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v3/deployments/dpl_1/events", + 200, + json!({ + "events": [ + {"created": 2_u64, "type": "stdout", "payload": "Building route /"}, + {"created": 3_u64, "type": "error", "payload": {"code": "BUILD_FAILED"}} + ] + }), + ) + .await; + + let logs = host(&server).deployment_logs("dpl_1").await.unwrap(); + + assert_eq!(logs.len(), 2); + assert_eq!(logs[0].created_at_ms, Some(2)); + assert_eq!(logs[0].kind, "stdout"); + assert_eq!(logs[0].message, "Building route /"); + assert_eq!(logs[1].kind, "error"); + assert_eq!(logs[1].message, r#"{\"code\":\"BUILD_FAILED\"}"#); +} + #[tokio::test] async fn promoting_resolves_the_project_first() { let server = MockServer::start().await; diff --git a/src/providers/vercel/wire.rs b/src/providers/vercel/wire.rs index e37fc1b..5659c3d 100644 --- a/src/providers/vercel/wire.rs +++ b/src/providers/vercel/wire.rs @@ -12,10 +12,11 @@ //! disagree about their names — `uid` against `id`, `state` against `readyState`. use serde::{Deserialize, Serialize}; +use serde_json::Value; use crate::host::types::{ - Database, DatabaseKind, Deployment, DeploymentStatus, DeploymentTarget, Domain, EnvVarRecord, - Framework, Site, + Database, DatabaseKind, Deployment, DeploymentLog, DeploymentStatus, DeploymentTarget, + Domain, EnvVarRecord, Framework, Site, }; /// The body of `POST /v11/projects`. @@ -140,6 +141,41 @@ pub(super) struct Deployments { pub(super) deployments: Vec, } +/// The envelope returned by `GET /v3/deployments/{id}/events`. +#[derive(Deserialize)] +pub(super) struct DeploymentEvents { + #[serde(default)] + pub(super) events: Vec, +} + +/// One Vercel deployment event. +#[derive(Deserialize)] +pub(super) struct DeploymentEvent { + #[serde(default)] + pub(super) created: Option, + #[serde(rename = "type")] + pub(super) kind: String, + #[serde(default)] + pub(super) payload: Option, +} + +impl DeploymentEvent { + /// Preserves a non-string payload as JSON rather than silently losing it. + pub(super) fn into_log(self) -> DeploymentLog { + let message = match self.payload { + Some(Value::String(message)) => message, + Some(payload) => payload.to_string(), + None => String::new(), + }; + + DeploymentLog { + created_at_ms: self.created, + kind: self.kind, + message, + } + } +} + /// One entry of the `POST /v10/projects/{id}/env` array body. #[derive(Serialize)] #[serde(rename_all = "camelCase")] diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index e7b868c..7725bef 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -16,8 +16,8 @@ use serde::{Deserialize, Serialize}; use crate::host::types::{ - AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, Deployment, Domain, - EnvVar, EnvVarRecord, Site, SiteSpec, + AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, Deployment, + DeploymentLog, Domain, EnvVar, EnvVarRecord, Site, SiteSpec, }; use crate::launch::types::{Launch, LaunchPlan}; use crate::providers::{ProviderKind, connect_to}; @@ -119,6 +119,11 @@ pub enum Operation { #[serde(default = "default_limit")] limit: u32, }, + /// List a deployment's build and runtime events, oldest first. + DeploymentLogs { + /// The deployment's identifier. + id: String, + }, /// Point production traffic at an existing deployment. Promote { /// The site's name or identifier. @@ -173,6 +178,8 @@ pub enum Outcome { Deployment(Deployment), /// Several deployments. Deployments(Vec), + /// A deployment's build and runtime events. + DeploymentLogs(Vec), /// A site's environment variables, without their values. Env(Vec), /// One database. @@ -230,6 +237,10 @@ pub async fn execute(request: Request) -> Result { .list_deployments(&site, limit) .await .map(Outcome::Deployments), + Operation::DeploymentLogs { id } => host + .deployment_logs(&id) + .await + .map(Outcome::DeploymentLogs), Operation::Promote { site, deployment } => host .promote(&site, &deployment) .await From 81381ba2844c70b1f26e4b7a51885eacf678cb2b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 15:57:52 +0300 Subject: [PATCH 2/3] chore(vercel, rpc): reformat imports and simplify match arm Reformat the import block in the Vercel wire module to keep lines within the project's style guide, and collapse a multi-line match arm in the RPC module into a single block for consistency with surrounding code. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/providers/vercel/wire.rs | 4 ++-- src/rpc/mod.rs | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/providers/vercel/wire.rs b/src/providers/vercel/wire.rs index 5659c3d..c72b491 100644 --- a/src/providers/vercel/wire.rs +++ b/src/providers/vercel/wire.rs @@ -15,8 +15,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::host::types::{ - Database, DatabaseKind, Deployment, DeploymentLog, DeploymentStatus, DeploymentTarget, - Domain, EnvVarRecord, Framework, Site, + Database, DatabaseKind, Deployment, DeploymentLog, DeploymentStatus, DeploymentTarget, Domain, + EnvVarRecord, Framework, Site, }; /// The body of `POST /v11/projects`. diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 7725bef..063022a 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -237,10 +237,9 @@ pub async fn execute(request: Request) -> Result { .list_deployments(&site, limit) .await .map(Outcome::Deployments), - Operation::DeploymentLogs { id } => host - .deployment_logs(&id) - .await - .map(Outcome::DeploymentLogs), + Operation::DeploymentLogs { id } => { + host.deployment_logs(&id).await.map(Outcome::DeploymentLogs) + } Operation::Promote { site, deployment } => host .promote(&site, &deployment) .await From c0ff4075dc9dabcc309581d478ac7ec753b22088 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 15:59:11 +0300 Subject: [PATCH 3/3] fix(vercel): correct escaped JSON in deployment event test The test assertion for the error log message was using an incorrectly escaped JSON string with backslashes before the quotes, which did not match the actual output from the deployment events endpoint. The fix removes the unnecessary escape characters so the test correctly validates the raw JSON response. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/providers/vercel/test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/vercel/test.rs b/src/providers/vercel/test.rs index 7a7de38..a56461e 100644 --- a/src/providers/vercel/test.rs +++ b/src/providers/vercel/test.rs @@ -516,7 +516,7 @@ async fn deployment_events_preserve_their_kind_message_and_timestamp() { assert_eq!(logs[0].kind, "stdout"); assert_eq!(logs[0].message, "Building route /"); assert_eq!(logs[1].kind, "error"); - assert_eq!(logs[1].message, r#"{\"code\":\"BUILD_FAILED\"}"#); + assert_eq!(logs[1].message, r#"{"code":"BUILD_FAILED"}"#); } #[tokio::test]