|
3 | 3 | //! The runner stays harness-agnostic: these are generic, ordered file operations. Content operations |
4 | 4 | //! gate an agent's boot; `git-exclude` is advisory and can never prevent a launch. |
5 | 5 |
|
6 | | -use std::collections::{BTreeMap, HashSet}; |
| 6 | +use std::collections::{BTreeMap, BTreeSet, HashSet}; |
7 | 7 | use std::fs; |
8 | 8 | use std::path::{Component, Path, PathBuf}; |
9 | 9 | use std::process::Command; |
@@ -452,6 +452,143 @@ enum PreparedOp { |
452 | 452 | }, |
453 | 453 | } |
454 | 454 |
|
| 455 | +#[derive(Debug, Clone, PartialEq)] |
| 456 | +enum RenderClaim { |
| 457 | + Replace(Vec<u8>), |
| 458 | + JsonUpsert(serde_json::Value), |
| 459 | + EnsureLine(String), |
| 460 | +} |
| 461 | + |
| 462 | +#[derive(Debug)] |
| 463 | +pub struct RenderOwnershipConflict { |
| 464 | + pub destination: PathBuf, |
| 465 | + pub owners: BTreeSet<String>, |
| 466 | +} |
| 467 | + |
| 468 | +impl RenderOwnershipConflict { |
| 469 | + fn error(&self) -> String { |
| 470 | + format!( |
| 471 | + "conflicting render ownership for '{}': active agents {} declare incompatible content for one shared workspace target", |
| 472 | + self.destination.display(), |
| 473 | + self.owners.iter().cloned().collect::<Vec<_>>().join(", ") |
| 474 | + ) |
| 475 | + } |
| 476 | +} |
| 477 | + |
| 478 | +fn claims_for_agent( |
| 479 | + root: &Path, |
| 480 | + spec: &AgentSpec, |
| 481 | + this_host: &str, |
| 482 | +) -> Result<BTreeMap<PathBuf, Vec<RenderClaim>>> { |
| 483 | + let plan = parse_plan(spec)?; |
| 484 | + if plan.ops.is_empty() { |
| 485 | + return Ok(BTreeMap::new()); |
| 486 | + } |
| 487 | + let workspace_raw = spec |
| 488 | + .workspace |
| 489 | + .as_deref() |
| 490 | + .with_context(|| format!("agent '{}' has render{{}} but no workspace", spec.identity))?; |
| 491 | + let env = render_env(root, spec, this_host); |
| 492 | + let workspace = PathBuf::from(expand(workspace_raw, &env)); |
| 493 | + let workspace = workspace |
| 494 | + .canonicalize() |
| 495 | + .with_context(|| format!("canonicalizing workspace {}", workspace.display()))?; |
| 496 | + let spec_dir = spec.path.parent().unwrap_or(root); |
| 497 | + let mut claims = BTreeMap::<PathBuf, Vec<RenderClaim>>::new(); |
| 498 | + for op in plan.ops { |
| 499 | + let (destination, claim) = match op { |
| 500 | + RenderOp::Copy { |
| 501 | + source: raw_source, |
| 502 | + destination: raw_destination, |
| 503 | + } => { |
| 504 | + let source = source(root, spec_dir, &raw_source, &env)?; |
| 505 | + let bytes = fs::read(&source) |
| 506 | + .with_context(|| format!("reading copy source {}", source.display()))?; |
| 507 | + ( |
| 508 | + destination(&workspace, &raw_destination, &env)?, |
| 509 | + RenderClaim::Replace(bytes), |
| 510 | + ) |
| 511 | + } |
| 512 | + RenderOp::File { |
| 513 | + destination: raw_destination, |
| 514 | + content, |
| 515 | + } => ( |
| 516 | + destination(&workspace, &raw_destination, &env)?, |
| 517 | + RenderClaim::Replace(expand(&content, &env).into_bytes()), |
| 518 | + ), |
| 519 | + RenderOp::JsonUpsert { |
| 520 | + destination: raw_destination, |
| 521 | + content, |
| 522 | + } => { |
| 523 | + let patch = serde_json::from_str(&expand(&content, &env)).with_context(|| { |
| 524 | + format!( |
| 525 | + "expanded json-upsert for '{}' is not valid JSON", |
| 526 | + spec.identity |
| 527 | + ) |
| 528 | + })?; |
| 529 | + ( |
| 530 | + destination(&workspace, &raw_destination, &env)?, |
| 531 | + RenderClaim::JsonUpsert(patch), |
| 532 | + ) |
| 533 | + } |
| 534 | + RenderOp::EnsureLine { |
| 535 | + destination: raw_destination, |
| 536 | + line, |
| 537 | + } => ( |
| 538 | + destination(&workspace, &raw_destination, &env)?, |
| 539 | + RenderClaim::EnsureLine(expand(&line, &env)), |
| 540 | + ), |
| 541 | + // Every git-exclude is additive by contract and resolves through Git's own shared |
| 542 | + // metadata path rather than a declared workspace-relative render destination. |
| 543 | + RenderOp::GitExclude { .. } => continue, |
| 544 | + }; |
| 545 | + claims.entry(destination).or_default().push(claim); |
| 546 | + } |
| 547 | + Ok(claims) |
| 548 | +} |
| 549 | + |
| 550 | +/// Find active local agents that declare incompatible desired content for one resolved workspace |
| 551 | +/// destination. Equivalent idempotent plans may share a target; differing plans have no implicit |
| 552 | +/// last-writer-wins semantics. |
| 553 | +pub fn render_ownership_conflicts( |
| 554 | + root: &Path, |
| 555 | + specs: &[AgentSpec], |
| 556 | + this_host: &str, |
| 557 | +) -> Vec<RenderOwnershipConflict> { |
| 558 | + let mut by_destination = BTreeMap::<PathBuf, BTreeMap<String, Vec<RenderClaim>>>::new(); |
| 559 | + for spec in specs { |
| 560 | + if spec.retired || spec.resolved_host(this_host) != this_host { |
| 561 | + continue; |
| 562 | + } |
| 563 | + let Ok(claims) = claims_for_agent(root, spec, this_host) else { |
| 564 | + // The normal per-agent materialization path reports malformed plans and unavailable |
| 565 | + // inputs. Ownership analysis only compares claims it can resolve without writing. |
| 566 | + continue; |
| 567 | + }; |
| 568 | + let owner = spec.bus_id(this_host); |
| 569 | + for (destination, plan) in claims { |
| 570 | + by_destination |
| 571 | + .entry(destination) |
| 572 | + .or_default() |
| 573 | + .insert(owner.clone(), plan); |
| 574 | + } |
| 575 | + } |
| 576 | + |
| 577 | + by_destination |
| 578 | + .into_iter() |
| 579 | + .filter_map(|(destination, claims)| { |
| 580 | + let mut plans = claims.values(); |
| 581 | + let first = plans.next()?; |
| 582 | + plans |
| 583 | + .any(|plan| plan != first) |
| 584 | + .then(|| RenderOwnershipConflict { |
| 585 | + destination, |
| 586 | + owners: claims.into_keys().collect(), |
| 587 | + }) |
| 588 | + }) |
| 589 | + .collect() |
| 590 | +} |
| 591 | + |
455 | 592 | /// Execute one agent's render plan in declaration order. |
456 | 593 | pub fn materialize_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Result<Vec<String>> { |
457 | 594 | let plan = parse_plan(spec)?; |
@@ -699,12 +836,45 @@ pub fn validate_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Result< |
699 | 836 |
|
700 | 837 | /// Materialize every active agent assigned to `this_host`. |
701 | 838 | pub fn materialize_catalog(root: &Path, specs: &[AgentSpec], this_host: &str) -> MaterializeReport { |
| 839 | + materialize_catalog_against(root, specs, specs, this_host) |
| 840 | +} |
| 841 | + |
| 842 | +/// Materialize `selected_specs` after checking their workspace target ownership against the complete |
| 843 | +/// active fleet. This preserves shortest-path selection while preventing a selected owner from |
| 844 | +/// bypassing a collision declared by an unselected sibling. |
| 845 | +pub fn materialize_catalog_against( |
| 846 | + root: &Path, |
| 847 | + selected_specs: &[AgentSpec], |
| 848 | + ownership_specs: &[AgentSpec], |
| 849 | + this_host: &str, |
| 850 | +) -> MaterializeReport { |
702 | 851 | let mut report = MaterializeReport::default(); |
703 | | - for spec in specs { |
| 852 | + let selected_ids = selected_specs |
| 853 | + .iter() |
| 854 | + .map(|spec| spec.bus_id(this_host)) |
| 855 | + .collect::<HashSet<_>>(); |
| 856 | + for conflict in render_ownership_conflicts(root, ownership_specs, this_host) { |
| 857 | + let affected = conflict |
| 858 | + .owners |
| 859 | + .iter() |
| 860 | + .filter(|owner| selected_ids.contains(*owner)) |
| 861 | + .cloned() |
| 862 | + .collect::<Vec<_>>(); |
| 863 | + if affected.is_empty() { |
| 864 | + continue; |
| 865 | + } |
| 866 | + report.failed_agents.extend(affected); |
| 867 | + report.errors.push(conflict.error()); |
| 868 | + } |
| 869 | + |
| 870 | + for spec in selected_specs { |
704 | 871 | if spec.retired || spec.resolved_host(this_host) != this_host { |
705 | 872 | continue; |
706 | 873 | } |
707 | 874 | let bus_id = spec.bus_id(this_host); |
| 875 | + if report.failed_agents.contains(&bus_id) { |
| 876 | + continue; |
| 877 | + } |
708 | 878 | match materialize_agent(root, spec, this_host) { |
709 | 879 | Ok(notes) => { |
710 | 880 | for note in notes { |
|
0 commit comments