max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+223 insertions,
-25 deletions
| @@ -3496,3 +3496,31 @@ | |||
| 3496 | 3496 | version = "1.0.21" | |
| 3497 | 3497 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3498 | 3498 | checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" | |
| 3499 | + | ||
| 3500 | + | [[patch.unused]] | |
| 3501 | + | name = "docengine" | |
| 3502 | + | version = "0.4.0" | |
| 3503 | + | ||
| 3504 | + | [[patch.unused]] | |
| 3505 | + | name = "synckit-client" | |
| 3506 | + | version = "0.6.0" | |
| 3507 | + | ||
| 3508 | + | [[patch.unused]] | |
| 3509 | + | name = "synckit-config" | |
| 3510 | + | version = "0.1.2" | |
| 3511 | + | ||
| 3512 | + | [[patch.unused]] | |
| 3513 | + | name = "supernote-push" | |
| 3514 | + | version = "0.1.0" | |
| 3515 | + | ||
| 3516 | + | [[patch.unused]] | |
| 3517 | + | name = "kberg" | |
| 3518 | + | version = "0.1.0" | |
| 3519 | + | ||
| 3520 | + | [[patch.unused]] | |
| 3521 | + | name = "painhours" | |
| 3522 | + | version = "0.1.0" | |
| 3523 | + | ||
| 3524 | + | [[patch.unused]] | |
| 3525 | + | name = "tagtree" | |
| 3526 | + | version = "0.4.0" |
| @@ -22,7 +22,7 @@ | |||
| 22 | 22 | # Collected artifacts land at <dist_root>/<app>/<version>/. | |
| 23 | 23 | dist_root = "/home/max/Dist" | |
| 24 | 24 | ||
| 25 | - | # Per-step run logs: <logs_root>/<app>/<version>/<target>/<step>.log | |
| 25 | + | # Per-step run logs: <logs_root>/<app>/<version>/<target>/<step>.<run_id>.log | |
| 26 | 26 | logs_root = "/home/max/.local/state/bento/logs" | |
| 27 | 27 | ||
| 28 | 28 | # Optional: override the per-step wall-clock budget (seconds) for EVERY step, |
| @@ -17,7 +17,7 @@ | |||
| 17 | 17 | /// Where collected artifacts land (`<dist_root>/<app>/<version>/`). | |
| 18 | 18 | pub dist_root: PathBuf, | |
| 19 | 19 | /// Root for per-step run logs | |
| 20 | - | /// (`<logs_root>/<app>/<version>/<target>/<step>.log`). | |
| 20 | + | /// (`<logs_root>/<app>/<version>/<target>/<step>.<run_id>.log`). | |
| 21 | 21 | #[serde(default = "default_logs_root")] | |
| 22 | 22 | pub logs_root: PathBuf, | |
| 23 | 23 | /// Override the per-step wall-clock budget (in seconds) for EVERY step, |
| @@ -304,15 +304,27 @@ | |||
| 304 | 304 | chrono::Utc::now().to_rfc3339() | |
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | - | /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/<step>.log`. | |
| 308 | - | fn log_path(&self, step: Step) -> PathBuf { | |
| 307 | + | /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/<step>.<run_id>.log`. | |
| 308 | + | /// | |
| 309 | + | /// The run id is in the filename because the rest of the key is not unique: | |
| 310 | + | /// re-running an app at a version it already built (a retry, or a rebuild of | |
| 311 | + | /// an already-published release) reopens the same path, and the old file | |
| 312 | + | /// appended to shows two runs' output with nothing marking the boundary. The | |
| 313 | + | /// step ledger records a run id per step, so keying the file on it makes a | |
| 314 | + | /// ledger row resolve to exactly one file and keeps the earlier run readable. | |
| 315 | + | fn log_path(&self, step: Step, run_id: StepRunId) -> PathBuf { | |
| 316 | + | self.log_dir() | |
| 317 | + | .join(format!("{}.{}.log", step.as_str(), run_id.0)) | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/`. | |
| 321 | + | fn log_dir(&self) -> PathBuf { | |
| 309 | 322 | let target_dir = self.target.to_string().replace('/', "-"); | |
| 310 | 323 | self.cfg | |
| 311 | 324 | .logs_root | |
| 312 | 325 | .join(self.app.as_str()) | |
| 313 | 326 | .join(self.version.to_string()) | |
| 314 | 327 | .join(target_dir) | |
| 315 | - | .join(format!("{}.log", step.as_str())) | |
| 316 | 328 | } | |
| 317 | 329 | ||
| 318 | 330 | /// Close the previous step (as `Ok`), open a new one: insert its DB row, | |
| @@ -325,24 +337,36 @@ | |||
| 325 | 337 | ); | |
| 326 | 338 | self.finish_step(Status::Ok)?; | |
| 327 | 339 | let me = self.clone(); | |
| 340 | + | let started = Self::now(); | |
| 341 | + | let started_for_header = started.clone(); | |
| 328 | 342 | let run_id = self.rt.block_on(async move { | |
| 329 | - | let started = Self::now(); | |
| 330 | - | let log_ref = me.log_path(step).to_string_lossy().into_owned(); | |
| 331 | 343 | // The step row and the target's current_step pointer are one logical | |
| 332 | 344 | // state — write them atomically so a failure can't leave a `running` | |
| 333 | 345 | // step row while current_step still names the previous step. | |
| 334 | 346 | let mut tx = me.pool.begin().await.context("begin step tx")?; | |
| 347 | + | // log_ref names the run id, which only exists once the row does, so | |
| 348 | + | // the path is written back inside the same transaction rather than | |
| 349 | + | // guessed beforehand. | |
| 335 | 350 | let id: i64 = sqlx::query_scalar( | |
| 336 | - | "INSERT INTO step_runs (target_run_id, step, status, log_ref, started_at) | |
| 337 | - | VALUES (?, ?, 'running', ?, ?) RETURNING id", | |
| 351 | + | "INSERT INTO step_runs (target_run_id, step, status, started_at) | |
| 352 | + | VALUES (?, ?, 'running', ?) RETURNING id", | |
| 338 | 353 | ) | |
| 339 | 354 | .bind(me.target_run_id) | |
| 340 | 355 | .bind(step.as_str()) | |
| 341 | - | .bind(&log_ref) | |
| 342 | 356 | .bind(&started) | |
| 343 | 357 | .fetch_one(&mut *tx) | |
| 344 | 358 | .await | |
| 345 | 359 | .context("insert step_run")?; | |
| 360 | + | let log_ref = me | |
| 361 | + | .log_path(step, StepRunId(id)) | |
| 362 | + | .to_string_lossy() | |
| 363 | + | .into_owned(); | |
| 364 | + | sqlx::query("UPDATE step_runs SET log_ref = ? WHERE id = ?") | |
| 365 | + | .bind(&log_ref) | |
| 366 | + | .bind(id) | |
| 367 | + | .execute(&mut *tx) | |
| 368 | + | .await | |
| 369 | + | .context("set step_run log_ref")?; | |
| 346 | 370 | sqlx::query("UPDATE target_runs SET current_step = ? WHERE id = ?") | |
| 347 | 371 | .bind(step.as_str()) | |
| 348 | 372 | .bind(me.target_run_id) | |
| @@ -356,8 +380,8 @@ | |||
| 356 | 380 | // Live log: each chunk fans out as a StepLogChunk event keyed by run_id. | |
| 357 | 381 | let events = self.events.clone(); | |
| 358 | 382 | let cb_run_id = run_id; | |
| 359 | - | let log = self.rt.block_on(LiveLog::open( | |
| 360 | - | self.log_path(step), | |
| 383 | + | let mut log = self.rt.block_on(LiveLog::open( | |
| 384 | + | self.log_path(step, run_id), | |
| 361 | 385 | Box::new(move |seq, text| { | |
| 362 | 386 | events::emit( | |
| 363 | 387 | &events, | |
| @@ -381,6 +405,23 @@ | |||
| 381 | 405 | }, | |
| 382 | 406 | ); | |
| 383 | 407 | ||
| 408 | + | // A log that names its own run: reading one tells you which ledger row | |
| 409 | + | // it belongs to without going back to the DB, and a file that somehow | |
| 410 | + | // does get appended to still shows where the second run began. Written | |
| 411 | + | // after `StepStart` so its chunk event cannot precede the step it | |
| 412 | + | // belongs to. | |
| 413 | + | let header = format!( | |
| 414 | + | "=== bento {app} {version} {target} step={step} run_id={run_id} started={started_for_header} ===\n", | |
| 415 | + | app = self.app.as_str(), | |
| 416 | + | version = self.version, | |
| 417 | + | target = self.target, | |
| 418 | + | step = step.as_str(), | |
| 419 | + | ); | |
| 420 | + | self.rt.block_on(async { | |
| 421 | + | use ops_core::remote::LogSink as _; | |
| 422 | + | log.write_chunk(header.as_bytes()).await; | |
| 423 | + | }); | |
| 424 | + | ||
| 384 | 425 | *self.current.lock().unwrap() = Some(StepState { | |
| 385 | 426 | run_id, | |
| 386 | 427 | step, | |
| @@ -557,12 +598,19 @@ | |||
| 557 | 598 | let exec = self.exec(host)?; | |
| 558 | 599 | let cur = self.current_step(); | |
| 559 | 600 | let step = OpStep::shell(action, cmd.to_string()); | |
| 601 | + | // Echo the command before running it. Without this a log says what | |
| 602 | + | // happened but not what was asked, and a gate that prints nothing when | |
| 603 | + | // it passes (`cargo fmt --all --check`) is indistinguishable from a gate | |
| 604 | + | // that never ran. | |
| 605 | + | let echo = format!("$ [{host}] {cmd}\n"); | |
| 560 | 606 | // Bounded by the step's deadline and interruptible on supersession, so a | |
| 561 | 607 | // hung command fails its step instead of running unbounded, and a | |
| 562 | 608 | // superseded build stops mid-step rather than only at the next boundary. | |
| 563 | 609 | let label = format!("{cur} command on `{host}`"); | |
| 564 | 610 | let out = self.run_bounded(&label, async move { | |
| 611 | + | use ops_core::remote::LogSink as _; | |
| 565 | 612 | let mut guard = sink.lock().await; | |
| 613 | + | guard.write_chunk(echo.as_bytes()).await; | |
| 566 | 614 | exec.run_streaming(&step, &mut *guard).await | |
| 567 | 615 | })?; | |
| 568 | 616 | let code = out.status.code().unwrap_or(-1); |
| @@ -406,9 +406,61 @@ | |||
| 406 | 406 | .collect() | |
| 407 | 407 | } | |
| 408 | 408 | ||
| 409 | + | #[derive(Deserialize)] | |
| 410 | + | pub(crate) struct StepLogQuery { | |
| 411 | + | /// Which run of this step to serve. Omitted, the newest wins. | |
| 412 | + | run_id: Option<i64>, | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | /// The file holding `step`'s output in `dir`. | |
| 416 | + | /// | |
| 417 | + | /// Logs are named `<step>.<run_id>.log` so re-running an app at a version it | |
| 418 | + | /// already built writes a new file instead of appending to the old one. The URL | |
| 419 | + | /// carries no run id, so without `?run_id=` this picks the highest — the run a | |
| 420 | + | /// reader asking for "the build log" means. Files written before the run id | |
| 421 | + | /// entered the name (`<step>.log`) are still served when nothing newer exists. | |
| 422 | + | async fn resolve_step_log( | |
| 423 | + | dir: &std::path::Path, | |
| 424 | + | step: &str, | |
| 425 | + | run_id: Option<i64>, | |
| 426 | + | ) -> Option<std::path::PathBuf> { | |
| 427 | + | if let Some(id) = run_id { | |
| 428 | + | let exact = dir.join(format!("{step}.{id}.log")); | |
| 429 | + | return tokio::fs::try_exists(&exact).await.ok()?.then_some(exact); | |
| 430 | + | } | |
| 431 | + | let mut entries = tokio::fs::read_dir(dir).await.ok()?; | |
| 432 | + | let mut newest: Option<(i64, std::path::PathBuf)> = None; | |
| 433 | + | while let Ok(Some(e)) = entries.next_entry().await { | |
| 434 | + | let name = e.file_name(); | |
| 435 | + | let Some(name) = name.to_str() else { continue }; | |
| 436 | + | let Some(rest) = name | |
| 437 | + | .strip_prefix(step) | |
| 438 | + | .and_then(|r| r.strip_prefix('.')) | |
| 439 | + | .and_then(|r| r.strip_suffix(".log")) | |
| 440 | + | else { | |
| 441 | + | continue; | |
| 442 | + | }; | |
| 443 | + | let Ok(id) = rest.parse::<i64>() else { | |
| 444 | + | continue; | |
| 445 | + | }; | |
| 446 | + | if newest.as_ref().is_none_or(|(best, _)| id > *best) { | |
| 447 | + | newest = Some((id, e.path())); | |
| 448 | + | } | |
| 449 | + | } | |
| 450 | + | match newest { | |
| 451 | + | Some((_, path)) => Some(path), | |
| 452 | + | // Legacy name, from before the run id was part of it. | |
| 453 | + | None => { | |
| 454 | + | let legacy = dir.join(format!("{step}.log")); | |
| 455 | + | tokio::fs::try_exists(&legacy).await.ok()?.then_some(legacy) | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | ||
| 409 | 460 | async fn get_step_log( | |
| 410 | 461 | State(s): State<AppState>, | |
| 411 | 462 | Path((app, version, target, step)): Path<(String, String, String, String)>, | |
| 463 | + | axum::extract::Query(q): axum::extract::Query<StepLogQuery>, | |
| 412 | 464 | ) -> Result<axum::response::Response> { | |
| 413 | 465 | fn safe(seg: &str) -> bool { | |
| 414 | 466 | !seg.is_empty() && !seg.contains('/') && !seg.contains('\\') && seg != "." && seg != ".." | |
| @@ -419,13 +471,10 @@ | |||
| 419 | 471 | { | |
| 420 | 472 | return Err(Error::NotFound); | |
| 421 | 473 | } | |
| 422 | - | let path = s | |
| 423 | - | .cfg | |
| 424 | - | .logs_root | |
| 425 | - | .join(&app) | |
| 426 | - | .join(&version) | |
| 427 | - | .join(&target) | |
| 428 | - | .join(format!("{step}.log")); | |
| 474 | + | let dir = s.cfg.logs_root.join(&app).join(&version).join(&target); | |
| 475 | + | let path = resolve_step_log(&dir, &step, q.run_id) | |
| 476 | + | .await | |
| 477 | + | .ok_or(Error::NotFound)?; | |
| 429 | 478 | // Stream the log in chunks rather than reading the whole (potentially large, | |
| 430 | 479 | // verbose-build) file into memory. | |
| 431 | 480 | let file = match tokio::fs::File::open(&path).await { | |
| @@ -685,11 +734,11 @@ | |||
| 685 | 734 | assert_eq!(resp.status(), StatusCode::NOT_FOUND); | |
| 686 | 735 | } | |
| 687 | 736 | ||
| 737 | + | /// A log written before the run id entered the filename is still served. | |
| 688 | 738 | #[tokio::test] | |
| 689 | 739 | async fn step_log_streams_existing_file() { | |
| 690 | 740 | let tmp = tempfile::tempdir().unwrap(); | |
| 691 | 741 | let state = test_state(tmp.path()).await; | |
| 692 | - | // Write a log at the path the route resolves. | |
| 693 | 742 | let dir = state | |
| 694 | 743 | .cfg | |
| 695 | 744 | .logs_root | |
| @@ -712,6 +761,56 @@ | |||
| 712 | 761 | assert_eq!(body_string(resp).await, "compiling\nlinked\n"); | |
| 713 | 762 | } | |
| 714 | 763 | ||
| 764 | + | /// Two runs of the same app/version/target/step no longer share a file, so | |
| 765 | + | /// the route has to choose: newest by default, exact on `?run_id=`. The | |
| 766 | + | /// legacy unsuffixed file must lose to any run-id-keyed one, or a stale | |
| 767 | + | /// pre-fix log would outrank every run since. | |
| 768 | + | #[tokio::test] | |
| 769 | + | async fn step_log_picks_newest_run_and_honours_run_id() { | |
| 770 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 771 | + | let state = test_state(tmp.path()).await; | |
| 772 | + | let dir = state | |
| 773 | + | .cfg | |
| 774 | + | .logs_root | |
| 775 | + | .join("goingson") | |
| 776 | + | .join("0.4.1") | |
| 777 | + | .join("linux-x86_64"); | |
| 778 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 779 | + | std::fs::write(dir.join("build.log"), b"legacy\n").unwrap(); | |
| 780 | + | std::fs::write(dir.join("build.55.log"), b"run 55\n").unwrap(); | |
| 781 | + | std::fs::write(dir.join("build.201.log"), b"run 201\n").unwrap(); | |
| 782 | + | // Same prefix, different step: must not be mistaken for a run of `build`. | |
| 783 | + | std::fs::write(dir.join("build_extra.9.log"), b"other\n").unwrap(); | |
| 784 | + | ||
| 785 | + | let fetch = |uri: &'static str| { | |
| 786 | + | let app = router(state.clone()); | |
| 787 | + | async move { | |
| 788 | + | app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) | |
| 789 | + | .await | |
| 790 | + | .unwrap() | |
| 791 | + | } | |
| 792 | + | }; | |
| 793 | + | ||
| 794 | + | let resp = fetch("/logs/goingson/0.4.1/linux-x86_64/build").await; | |
| 795 | + | assert_eq!(resp.status(), StatusCode::OK); | |
| 796 | + | assert_eq!( | |
| 797 | + | body_string(resp).await, | |
| 798 | + | "run 201\n", | |
| 799 | + | "highest run id wins, and 201 must beat 55 numerically rather than lexically" | |
| 800 | + | ); | |
| 801 | + | ||
| 802 | + | let resp = fetch("/logs/goingson/0.4.1/linux-x86_64/build?run_id=55").await; | |
| 803 | + | assert_eq!(resp.status(), StatusCode::OK); | |
| 804 | + | assert_eq!(body_string(resp).await, "run 55\n"); | |
| 805 | + | ||
| 806 | + | let resp = fetch("/logs/goingson/0.4.1/linux-x86_64/build?run_id=999").await; | |
| 807 | + | assert_eq!( | |
| 808 | + | resp.status(), | |
| 809 | + | StatusCode::NOT_FOUND, | |
| 810 | + | "an unknown run id is a miss, not a silent fall back to the newest" | |
| 811 | + | ); | |
| 812 | + | } | |
| 813 | + | ||
| 715 | 814 | #[tokio::test] | |
| 716 | 815 | async fn bad_request_body_is_a_json_error_envelope() { | |
| 717 | 816 | let tmp = tempfile::tempdir().unwrap(); |
| @@ -1303,12 +1303,35 @@ | |||
| 1303 | 1303 | // Artifact landed in dist_root and a step log was written. | |
| 1304 | 1304 | let artifact = state.cfg.dist_root.join("demo/0.0.1/demo.bin"); | |
| 1305 | 1305 | assert!(artifact.exists(), "collect should copy the artifact"); | |
| 1306 | - | let log = state | |
| 1307 | - | .cfg | |
| 1308 | - | .logs_root | |
| 1309 | - | .join("demo/0.0.1/linux-x86_64/build.log"); | |
| 1306 | + | // Read the path off the ledger rather than rebuilding it: the log is | |
| 1307 | + | // named for its step run id, and the point of that is that the row | |
| 1308 | + | // resolves to exactly one file. | |
| 1309 | + | let (run_id, log_ref): (i64, String) = sqlx::query_as( | |
| 1310 | + | "SELECT id, log_ref FROM step_runs WHERE step = 'build' AND target_run_id IN \ | |
| 1311 | + | (SELECT id FROM target_runs WHERE build_id = ?)", | |
| 1312 | + | ) | |
| 1313 | + | .bind(build_id) | |
| 1314 | + | .fetch_one(&pool) | |
| 1315 | + | .await | |
| 1316 | + | .unwrap(); | |
| 1317 | + | let log = std::path::PathBuf::from(&log_ref); | |
| 1318 | + | assert_eq!( | |
| 1319 | + | log, | |
| 1320 | + | state | |
| 1321 | + | .cfg | |
| 1322 | + | .logs_root | |
| 1323 | + | .join(format!("demo/0.0.1/linux-x86_64/build.{run_id}.log")), | |
| 1324 | + | "log path should be keyed on the step run id" | |
| 1325 | + | ); | |
| 1310 | 1326 | assert!(log.exists(), "build step log should exist"); | |
| 1311 | - | assert!(std::fs::read_to_string(&log).unwrap().contains("compiling")); | |
| 1327 | + | let body = std::fs::read_to_string(&log).unwrap(); | |
| 1328 | + | assert!(body.contains("compiling")); | |
| 1329 | + | assert!( | |
| 1330 | + | body.starts_with(&format!( | |
| 1331 | + | "=== bento demo 0.0.1 linux/x86_64 step=build run_id={run_id} " | |
| 1332 | + | )), | |
| 1333 | + | "log should open with a run header naming its run: {body}" | |
| 1334 | + | ); | |
| 1312 | 1335 | } | |
| 1313 | 1336 | ||
| 1314 | 1337 | /// Multi-target fan-out, which is what the daemon exists for: every other |