Author:
Hash:
Timestamp:
+285 -5 +/-9 browse
Kevin Schoon [me@kevinschoon.com]
449e42d399db5e8dcca1878c8300aa48bb8fa995
Wed, 13 May 2026 08:24:41 +0000 (4 weeks ago)
| 1 | diff --git a/Cargo.lock b/Cargo.lock |
| 2 | index 3e41c76..610876f 100644 |
| 3 | --- a/Cargo.lock |
| 4 | +++ b/Cargo.lock |
| 5 | @@ -529,6 +529,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 6 | checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" |
| 7 | |
| 8 | [[package]] |
| 9 | + name = "bytecount" |
| 10 | + version = "0.6.9" |
| 11 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
| 12 | + checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" |
| 13 | + |
| 14 | + [[package]] |
| 15 | name = "byteorder" |
| 16 | version = "1.5.0" |
| 17 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 18 | @@ -2033,6 +2039,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 19 | checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" |
| 20 | |
| 21 | [[package]] |
| 22 | + name = "papergrid" |
| 23 | + version = "0.17.0" |
| 24 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
| 25 | + checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" |
| 26 | + dependencies = [ |
| 27 | + "bytecount", |
| 28 | + "fnv", |
| 29 | + "unicode-width", |
| 30 | + ] |
| 31 | + |
| 32 | + [[package]] |
| 33 | name = "parking_lot" |
| 34 | version = "0.12.5" |
| 35 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 36 | @@ -2126,6 +2143,28 @@ dependencies = [ |
| 37 | ] |
| 38 | |
| 39 | [[package]] |
| 40 | + name = "proc-macro-error-attr2" |
| 41 | + version = "2.0.0" |
| 42 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
| 43 | + checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" |
| 44 | + dependencies = [ |
| 45 | + "proc-macro2", |
| 46 | + "quote", |
| 47 | + ] |
| 48 | + |
| 49 | + [[package]] |
| 50 | + name = "proc-macro-error2" |
| 51 | + version = "2.0.1" |
| 52 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
| 53 | + checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" |
| 54 | + dependencies = [ |
| 55 | + "proc-macro-error-attr2", |
| 56 | + "proc-macro2", |
| 57 | + "quote", |
| 58 | + "syn", |
| 59 | + ] |
| 60 | + |
| 61 | + [[package]] |
| 62 | name = "proc-macro2" |
| 63 | version = "1.0.106" |
| 64 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 65 | @@ -2197,7 +2236,7 @@ dependencies = [ |
| 66 | "once_cell", |
| 67 | "socket2", |
| 68 | "tracing", |
| 69 | - "windows-sys 0.52.0", |
| 70 | + "windows-sys 0.59.0", |
| 71 | ] |
| 72 | |
| 73 | [[package]] |
| 74 | @@ -2207,10 +2246,13 @@ dependencies = [ |
| 75 | "ayllu_api", |
| 76 | "ayllu_cmd", |
| 77 | "ayllu_config", |
| 78 | + "ayllu_database", |
| 79 | "ayllu_git", |
| 80 | "reqwest", |
| 81 | "serde", |
| 82 | + "tabled", |
| 83 | "thiserror 2.0.18", |
| 84 | + "timeutil", |
| 85 | "tokio", |
| 86 | "tracing", |
| 87 | "tracing-subscriber", |
| 88 | @@ -2908,6 +2950,30 @@ dependencies = [ |
| 89 | ] |
| 90 | |
| 91 | [[package]] |
| 92 | + name = "tabled" |
| 93 | + version = "0.20.0" |
| 94 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
| 95 | + checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" |
| 96 | + dependencies = [ |
| 97 | + "papergrid", |
| 98 | + "tabled_derive", |
| 99 | + "testing_table", |
| 100 | + ] |
| 101 | + |
| 102 | + [[package]] |
| 103 | + name = "tabled_derive" |
| 104 | + version = "0.11.0" |
| 105 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
| 106 | + checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846" |
| 107 | + dependencies = [ |
| 108 | + "heck", |
| 109 | + "proc-macro-error2", |
| 110 | + "proc-macro2", |
| 111 | + "quote", |
| 112 | + "syn", |
| 113 | + ] |
| 114 | + |
| 115 | + [[package]] |
| 116 | name = "tar" |
| 117 | version = "0.4.45" |
| 118 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 119 | @@ -2932,6 +2998,15 @@ dependencies = [ |
| 120 | ] |
| 121 | |
| 122 | [[package]] |
| 123 | + name = "testing_table" |
| 124 | + version = "0.3.0" |
| 125 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
| 126 | + checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" |
| 127 | + dependencies = [ |
| 128 | + "unicode-width", |
| 129 | + ] |
| 130 | + |
| 131 | + [[package]] |
| 132 | name = "thiserror" |
| 133 | version = "1.0.69" |
| 134 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 135 | diff --git a/crates/cmd/src/quipu.rs b/crates/cmd/src/quipu.rs |
| 136 | index abb32ff..f810f15 100644 |
| 137 | --- a/crates/cmd/src/quipu.rs |
| 138 | +++ b/crates/cmd/src/quipu.rs |
| 139 | @@ -40,6 +40,22 @@ pub enum Collections { |
| 140 | } |
| 141 | |
| 142 | #[derive(clap::Subcommand, Debug, PartialEq)] |
| 143 | + pub enum Build { |
| 144 | + /// Inspect build logs |
| 145 | + Logs { |
| 146 | + manifest: Option<i32>, |
| 147 | + step: Option<i32>, |
| 148 | + }, |
| 149 | + /// List builds and their current status |
| 150 | + List, |
| 151 | + /// Inspect the status of a build |
| 152 | + Inspect { |
| 153 | + /// Manifest identifier |
| 154 | + id: Option<i32>, |
| 155 | + }, |
| 156 | + } |
| 157 | + |
| 158 | + #[derive(clap::Subcommand, Debug, PartialEq)] |
| 159 | pub enum Subcommand { |
| 160 | /// Generate autocomplete commands for common shells |
| 161 | // Complete { |
| 162 | @@ -53,4 +69,7 @@ pub enum Subcommand { |
| 163 | /// Collection and repository management |
| 164 | #[command(subcommand)] |
| 165 | Collections(Collections), |
| 166 | + /// Build management |
| 167 | + #[command(subcommand)] |
| 168 | + Build(Build), |
| 169 | } |
| 170 | diff --git a/crates/database/src/build.rs b/crates/database/src/build.rs |
| 171 | index ce26d4b..35035e0 100644 |
| 172 | --- a/crates/database/src/build.rs |
| 173 | +++ b/crates/database/src/build.rs |
| 174 | @@ -152,6 +152,21 @@ pub mod manifests { |
| 175 | .load(self.0) |
| 176 | } |
| 177 | |
| 178 | + pub fn manifest_latest_id( |
| 179 | + &mut self, |
| 180 | + collection: &str, |
| 181 | + name: &str, |
| 182 | + ) -> Result<Option<i32>, Error> { |
| 183 | + Ok(table |
| 184 | + .select(dsl::id) |
| 185 | + .filter(dsl::collection.eq(collection)) |
| 186 | + .filter(dsl::name.eq(name)) |
| 187 | + .order_by(dsl::id.desc()) |
| 188 | + .limit(1) |
| 189 | + .get_result(self.0) |
| 190 | + .ok()) |
| 191 | + } |
| 192 | + |
| 193 | pub fn manifest_set_dag_content( |
| 194 | &mut self, |
| 195 | id: i32, |
| 196 | @@ -224,6 +239,16 @@ pub mod workflows { |
| 197 | .get_result(self.0) |
| 198 | } |
| 199 | |
| 200 | + pub fn workflow_latest_id(&mut self, manifest_id: i32) -> Result<Option<i32>, Error> { |
| 201 | + Ok(table |
| 202 | + .select(dsl::id) |
| 203 | + .filter(dsl::manifest_id.eq(manifest_id)) |
| 204 | + .order_by(dsl::id.desc()) |
| 205 | + .limit(1) |
| 206 | + .get_result(self.0) |
| 207 | + .ok()) |
| 208 | + } |
| 209 | + |
| 210 | pub fn workflow_read(&mut self, workflow_id: i32) -> Result<Workflow, Error> { |
| 211 | table |
| 212 | .select(Workflow::as_select()) |
| 213 | diff --git a/crates/timeutil/src/lib.rs b/crates/timeutil/src/lib.rs |
| 214 | index daf49d1..22e96fb 100644 |
| 215 | --- a/crates/timeutil/src/lib.rs |
| 216 | +++ b/crates/timeutil/src/lib.rs |
| 217 | @@ -58,9 +58,15 @@ pub fn friendly(seconds: u64) -> String { |
| 218 | message |
| 219 | } |
| 220 | |
| 221 | + pub fn timestamp_from_epoch(seconds: i64) -> String { |
| 222 | + let ts = time::OffsetDateTime::from_unix_timestamp(seconds).unwrap(); |
| 223 | + ts.format(&time::format_description::well_known::Rfc3339) |
| 224 | + .unwrap() |
| 225 | + } |
| 226 | + |
| 227 | pub fn timestamp() -> String { |
| 228 | time::OffsetDateTime::now_utc() |
| 229 | - .format(&time::format_description::well_known::Iso8601::DEFAULT) |
| 230 | + .format(&time::format_description::well_known::Rfc3339) |
| 231 | .unwrap() |
| 232 | } |
| 233 | |
| 234 | diff --git a/quipu/Cargo.toml b/quipu/Cargo.toml |
| 235 | index bc792f6..410c79a 100644 |
| 236 | --- a/quipu/Cargo.toml |
| 237 | +++ b/quipu/Cargo.toml |
| 238 | @@ -9,8 +9,10 @@ name = "quipu" |
| 239 | [dependencies] |
| 240 | ayllu_api = { path = "../crates/api" } |
| 241 | ayllu_config = { path = "../crates/config" } |
| 242 | - ayllu_cmd = {path = "../crates/cmd"} |
| 243 | - ayllu_git = {path = "../crates/git"} |
| 244 | + ayllu_cmd = { path = "../crates/cmd" } |
| 245 | + ayllu_database = { path = "../crates/database" } |
| 246 | + ayllu_git = { path = "../crates/git" } |
| 247 | + timeutil = { path = "../crates/timeutil" } |
| 248 | |
| 249 | tokio = { workspace = true } |
| 250 | reqwest = { workspace = true } |
| 251 | @@ -20,3 +22,4 @@ thiserror = { workspace = true } |
| 252 | serde = { workspace = true } |
| 253 | url = { workspace = true } |
| 254 | webfinger-rs = { workspace = true, features = ["reqwest"] } |
| 255 | + tabled = "0.20.0" |
| 256 | diff --git a/quipu/src/config.rs b/quipu/src/config.rs |
| 257 | index be75132..000fa1e 100644 |
| 258 | --- a/quipu/src/config.rs |
| 259 | +++ b/quipu/src/config.rs |
| 260 | @@ -2,7 +2,7 @@ use ayllu_git::Collection; |
| 261 | use serde::{Deserialize, Serialize}; |
| 262 | use url::Url; |
| 263 | |
| 264 | - use ayllu_config::Configurable; |
| 265 | + use ayllu_config::{Configurable, Database}; |
| 266 | |
| 267 | #[derive(Clone, Debug, Deserialize, Serialize)] |
| 268 | pub struct Instance { |
| 269 | @@ -19,6 +19,8 @@ pub struct Quipu { |
| 270 | pub struct Config { |
| 271 | pub log_level: String, |
| 272 | pub quipu: Option<Quipu>, |
| 273 | + #[serde(default = "Database::default")] |
| 274 | + pub database: Database, |
| 275 | #[serde(default = "Vec::new")] |
| 276 | pub collections: Vec<Collection>, |
| 277 | } |
| 278 | diff --git a/quipu/src/error.rs b/quipu/src/error.rs |
| 279 | index 27e9d98..ae47ae4 100644 |
| 280 | --- a/quipu/src/error.rs |
| 281 | +++ b/quipu/src/error.rs |
| 282 | @@ -23,4 +23,8 @@ pub enum QuipuError { |
| 283 | Finger(#[from] webfinger_rs::Error), |
| 284 | #[error("Git Error: {0}")] |
| 285 | Git(#[from] ayllu_git::Error), |
| 286 | + #[error("Database: {0}")] |
| 287 | + Database(#[from] ayllu_database::Error), |
| 288 | + #[error("Migration: {0}")] |
| 289 | + Migration(#[from] ayllu_database::MigrationError), |
| 290 | } |
| 291 | diff --git a/quipu/src/main.rs b/quipu/src/main.rs |
| 292 | index e83d9d5..9cc2f22 100644 |
| 293 | --- a/quipu/src/main.rs |
| 294 | +++ b/quipu/src/main.rs |
| 295 | @@ -6,11 +6,13 @@ use url::Url; |
| 296 | |
| 297 | use ayllu_cmd::quipu::{Command, Subcommand}; |
| 298 | use ayllu_config::Reader; |
| 299 | + use ayllu_database::Wrapper as Database; |
| 300 | |
| 301 | mod client; |
| 302 | mod config; |
| 303 | mod error; |
| 304 | mod output; |
| 305 | + mod ui; |
| 306 | |
| 307 | fn get_instance( |
| 308 | cfg: &config::Config, |
| 309 | @@ -87,5 +89,71 @@ async fn main() -> Result<(), error::QuipuError> { |
| 310 | Ok(()) |
| 311 | } |
| 312 | }, |
| 313 | + Subcommand::Build(build) => { |
| 314 | + let mut db = Database::new_ro(&cfg.database.path)?; |
| 315 | + let mut conn = db.call(); |
| 316 | + let current_path = std::env::current_dir()?.canonicalize()?; |
| 317 | + let (collection, name) = ayllu_git::collection_and_name(current_path.as_path()); |
| 318 | + match build { |
| 319 | + ayllu_cmd::quipu::Build::List => { |
| 320 | + let manifests = conn.manifest_list(&collection, &name)?; |
| 321 | + ui::print_builds(manifests.as_slice()); |
| 322 | + Ok(()) |
| 323 | + } |
| 324 | + ayllu_cmd::quipu::Build::Inspect { id } => { |
| 325 | + let id = if let Some(id) = id { |
| 326 | + id |
| 327 | + } else { |
| 328 | + if let Some(manifest_id) = conn.manifest_latest_id(&collection, &name)? { |
| 329 | + manifest_id |
| 330 | + } else { |
| 331 | + tracing::info!("No recent builds"); |
| 332 | + return Ok(()); |
| 333 | + } |
| 334 | + }; |
| 335 | + let summary = conn.manifest_summary(id)?; |
| 336 | + ui::print_build(&summary); |
| 337 | + Ok(()) |
| 338 | + } |
| 339 | + ayllu_cmd::quipu::Build::Logs { manifest, step } => { |
| 340 | + let step_ids = if let Some(step_id) = step { |
| 341 | + vec![step_id] |
| 342 | + } else { |
| 343 | + let manifest_id = if let Some(id) = manifest { |
| 344 | + id |
| 345 | + } else { |
| 346 | + if let Some(manifest_id) = |
| 347 | + conn.manifest_latest_id(&collection, &name)? |
| 348 | + { |
| 349 | + manifest_id |
| 350 | + } else { |
| 351 | + tracing::info!("No recent builds"); |
| 352 | + return Ok(()); |
| 353 | + } |
| 354 | + }; |
| 355 | + let summary = conn.manifest_summary(manifest_id)?; |
| 356 | + summary |
| 357 | + .workflows |
| 358 | + .iter() |
| 359 | + .fold(Vec::new(), |mut accm, (_, steps)| { |
| 360 | + accm.extend(steps.iter().map(|step| step.id).collect::<Vec<i32>>()); |
| 361 | + accm |
| 362 | + }) |
| 363 | + }; |
| 364 | + step_ids.iter().try_for_each(|step_id| { |
| 365 | + let lines = conn.log_read(*step_id, None)?; |
| 366 | + lines.iter().for_each(|line| match line.stream { |
| 367 | + ayllu_database::build::logs::Stream::Stdout => { |
| 368 | + println!("{}", line.line) |
| 369 | + } |
| 370 | + ayllu_database::build::logs::Stream::Stderr => { |
| 371 | + eprintln!("{}", line.line) |
| 372 | + } |
| 373 | + }); |
| 374 | + Ok(()) |
| 375 | + }) |
| 376 | + } |
| 377 | + } |
| 378 | + } |
| 379 | } |
| 380 | } |
| 381 | diff --git a/quipu/src/ui.rs b/quipu/src/ui.rs |
| 382 | new file mode 100644 |
| 383 | index 0000000..a1026ae |
| 384 | --- /dev/null |
| 385 | +++ b/quipu/src/ui.rs |
| 386 | @@ -0,0 +1,78 @@ |
| 387 | + use ayllu_database::build::{ |
| 388 | + State, |
| 389 | + manifests::{Manifest, Summary}, |
| 390 | + }; |
| 391 | + use tabled::{Table, Tabled, settings::Style}; |
| 392 | + |
| 393 | + #[derive(Tabled)] |
| 394 | + struct ManifestItem { |
| 395 | + pub id: i32, |
| 396 | + pub state: State, |
| 397 | + pub created_at: String, |
| 398 | + pub runtime: String, |
| 399 | + } |
| 400 | + |
| 401 | + #[derive(Tabled)] |
| 402 | + struct StepItem { |
| 403 | + pub id: i32, |
| 404 | + pub workflow: String, |
| 405 | + pub state: State, |
| 406 | + pub runtime: String, |
| 407 | + } |
| 408 | + |
| 409 | + fn runtime(started_at: Option<i32>, finished_at: Option<i32>) -> String { |
| 410 | + match (started_at, finished_at) { |
| 411 | + (Some(started_at), Some(finished_at)) => { |
| 412 | + format!("{}s", finished_at - started_at) |
| 413 | + } |
| 414 | + _ => String::from("?"), |
| 415 | + } |
| 416 | + } |
| 417 | + |
| 418 | + pub fn print_builds(manifests: &[Manifest]) { |
| 419 | + let items: Vec<ManifestItem> = manifests |
| 420 | + .iter() |
| 421 | + .map(|manifest| ManifestItem { |
| 422 | + id: manifest.id, |
| 423 | + state: manifest.state, |
| 424 | + created_at: timeutil::timestamp_from_epoch(manifest.created_at as i64), |
| 425 | + runtime: runtime(manifest.started_at, manifest.finished_at), |
| 426 | + }) |
| 427 | + .collect(); |
| 428 | + let mut table = Table::new(items); |
| 429 | + table.with(Style::modern()); |
| 430 | + println!("{table}"); |
| 431 | + } |
| 432 | + |
| 433 | + pub fn print_build(summary: &Summary) { |
| 434 | + let manifest_item = ManifestItem { |
| 435 | + id: summary.manifest.id, |
| 436 | + state: summary.manifest.state, |
| 437 | + created_at: timeutil::timestamp_from_epoch(summary.manifest.created_at as i64), |
| 438 | + runtime: runtime(summary.manifest.started_at, summary.manifest.finished_at), |
| 439 | + }; |
| 440 | + let mut table = Table::new(vec![manifest_item]); |
| 441 | + table.with(Style::modern()); |
| 442 | + println!("{table:}"); |
| 443 | + let steps: Vec<StepItem> = |
| 444 | + summary |
| 445 | + .workflows |
| 446 | + .iter() |
| 447 | + .fold(Vec::new(), |mut accm, (workflow, steps)| { |
| 448 | + let steps: Vec<StepItem> = steps |
| 449 | + .iter() |
| 450 | + .map(|step| StepItem { |
| 451 | + id: step.id, |
| 452 | + workflow: workflow.name.clone(), |
| 453 | + state: step.state, |
| 454 | + runtime: runtime(step.started_at, step.finished_at), |
| 455 | + }) |
| 456 | + .collect(); |
| 457 | + accm.extend(steps); |
| 458 | + accm |
| 459 | + }); |
| 460 | + |
| 461 | + let mut table = Table::new(steps); |
| 462 | + table.with(Style::modern()); |
| 463 | + println!("{table}") |
| 464 | + } |