Skip to content

Commit 09575dc

Browse files
fix(app): reject apps referencing not-yet-runnable agents (#161) (#164)
* fix(app): reject apps referencing not-yet-runnable agents at validate/compile (#161) The html-report agent advertised a curated `render` command + an aware-html-report cli transport, but no such binary ships or is installable — so an app node using it validated/compiled fine and only failed at run with "program not found". - New agent-manifest `status: available | planned` (default available). html-report is marked `planned` until its binary ships. - validate_app_agents rejects (E_APP_AGENT_UNAVAILABLE) any node referencing a `planned` agent, recursing into for-each `do:` bodies. Wired into `app validate`, `compile_to_disk` (so no lock is produced), and the real-run pre-flight. - `aware agent describe` shows a "planned — not yet runnable" status line so the agent no longer looks runnable. Note: two showcase example apps (bim-monday-audit, engineer-peer-review-delta) reference html-report and now correctly fail validate — they were never runnable (html-report has no binary). Tracked as a follow-up to ship html-report or revise those examples. * fix(app): enforce planned-agent check on dry-run + install (#161 review) - The planned-agent pre-flight sat inside the `!dry_run` block, but a plain `--dry-run` still dispatches read-mode nodes, so html-report.render fell through to the spawn failure this change replaces. The check now runs for plain runs and `--dry-run`; only `--simulate` (stubs every node, contacts no binary) skips it. - `aware app install` ran only validate_app_safety, so an app referencing a planned agent could install + lock despite failing validate/compile. Added validate_app_agents to the install pre-copy validation. Found by Codex review.
1 parent 98f598d commit 09575dc

7 files changed

Lines changed: 188 additions & 9 deletions

File tree

20-agents/_core/html-report/manifest.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ description: |
1212
1313
stateful: false
1414

15+
# Not yet runnable: the `aware-html-report` transport binary isn't shipped or
16+
# installable. Apps referencing it are rejected at validate/compile rather than
17+
# failing at run with "program not found" (#161). Flip to `available` once the
18+
# binary ships.
19+
status: planned
20+
1521
vendor: aware
1622
license: Apache-2.0
1723
homepage: https://github.com/aware-aeco/aware/tree/main/20-agents/_core/html-report

cli/src/app_lock.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,18 @@ pub fn compile_to_disk(source: &Path, paths: &Paths) -> Result<std::path::PathBu
532532
)));
533533
}
534534
let agents = discover_agents(paths)?;
535+
// Refuse to lock an app that references a not-yet-runnable agent (e.g.
536+
// html-report, whose transport binary isn't shipped) — fail here, not at run
537+
// with "program not found" (#161).
538+
if let Some(err) = crate::validate::validate_app_agents(&app, &agents)
539+
.into_iter()
540+
.find(|i| i.severity == crate::validate::Severity::Error)
541+
{
542+
return Err(AwareError::Validation(format!(
543+
"app failed validation: [{}] {}",
544+
err.code, err.message
545+
)));
546+
}
535547
let lock = compile(&app, &agents, source)?;
536548
write_lockfile(&lock, source)
537549
}

cli/src/commands/agent.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,12 @@ fn describe(ctx: &Context, agent_id: &str) -> Result<(), AwareError> {
479479
if let Some(t) = &m.transport.cli {
480480
println!("transport: cli ({})", t.binary);
481481
}
482+
if m.status == crate::manifest::agent::AgentStatus::Planned {
483+
println!(
484+
"status: \u{26a0} planned — not yet runnable (no shipped transport binary); \
485+
apps referencing it are rejected at validate/compile (#161)"
486+
);
487+
}
482488
println!();
483489
let curated = m.curated_count();
484490
let reflected = m.reflected_count();

cli/src/commands/app.rs

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -174,17 +174,34 @@ async fn run(
174174
// nodes are missing `safety:` blocks. Skipped in --dry-run (a dry-run
175175
// is precisely how you'd test an app's safety contract before adding
176176
// the blocks). See `10-core/app-spec.md § Safety contract`.
177-
if !dry_run {
177+
// Pre-flight checks need the agent catalogue. `--simulate` stubs every node
178+
// and contacts no binary, so it skips both checks; a plain `--dry-run` still
179+
// dispatches read-mode nodes, so it gets the planned-agent check.
180+
if !simulate {
178181
let agents = crate::manifest::loader::discover_agents(&ctx.paths)?;
179-
let safety_issues = crate::validate::validate_app_safety(&app, &agents);
180-
if !safety_issues.is_empty() {
181-
eprintln!("error: app failed safety pre-flight (use --dry-run to preview):");
182-
for issue in &safety_issues {
183-
eprintln!(" \u{2717} [{}] {}", issue.code, issue.message);
182+
183+
// Planned-agent check: a plain `--dry-run` still dispatches to live read-mode
184+
// binaries (only `--simulate`, excluded above, stubs everything), so refuse a
185+
// not-yet-runnable agent with a clear reason instead of a downstream
186+
// "program not found" (#161).
187+
if let Some(err) = crate::validate::validate_app_agents(&app, &agents).first() {
188+
eprintln!("error: {}", err.message);
189+
return Err(AwareError::Validation(format!("[{}]", err.code)));
190+
}
191+
192+
// Safety pre-flight only gates real runs (dry-run is precisely how you test
193+
// an app's safety contract before adding the blocks).
194+
if !dry_run {
195+
let safety_issues = crate::validate::validate_app_safety(&app, &agents);
196+
if !safety_issues.is_empty() {
197+
eprintln!("error: app failed safety pre-flight (use --dry-run to preview):");
198+
for issue in &safety_issues {
199+
eprintln!(" \u{2717} [{}] {}", issue.code, issue.message);
200+
}
201+
return Err(AwareError::Validation(
202+
"write-mode node(s) missing `safety:` block".into(),
203+
));
184204
}
185-
return Err(AwareError::Validation(
186-
"write-mode node(s) missing `safety:` block".into(),
187-
));
188205
}
189206
}
190207

@@ -513,6 +530,9 @@ fn install(ctx: &Context, spec: &str) -> Result<(), AwareError> {
513530
let mut issues = crate::validate::validate_app(&src_app);
514531
if let Ok(agents) = crate::manifest::loader::discover_agents(&ctx.paths) {
515532
issues.extend(crate::validate::validate_app_safety(&src_app, &agents));
533+
// Don't install an app that references a not-yet-runnable agent — install
534+
// must enforce the same contract as validate/compile (#161).
535+
issues.extend(crate::validate::validate_app_agents(&src_app, &agents));
516536
}
517537
if crate::validate::has_errors(&issues) {
518538
for i in &issues {
@@ -591,6 +611,7 @@ fn validate_cmd(ctx: &Context, path: &std::path::Path) -> Result<(), AwareError>
591611
// be validating an app before installing its agents).
592612
if let Ok(agents) = crate::manifest::loader::discover_agents(&ctx.paths) {
593613
issues.extend(crate::validate::validate_app_safety(&app, &agents));
614+
issues.extend(crate::validate::validate_app_agents(&app, &agents));
594615
}
595616

596617
if issues.is_empty() {

cli/src/manifest/agent.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ pub struct Agent {
4141
pub display_name: Option<String>,
4242
pub description: String,
4343
pub stateful: bool,
44+
/// Runnability of the agent's transport. `available` (default) means the
45+
/// transport binary ships / is installable; `planned` means the agent is
46+
/// declared but not yet runnable (no shipped/installable binary), so apps
47+
/// referencing it are rejected at validate/compile rather than failing at run
48+
/// with "program not found" (#161).
49+
#[serde(default)]
50+
pub status: AgentStatus,
4451
pub vendor: Option<String>,
4552
pub license: String,
4653
#[allow(dead_code)]
@@ -73,6 +80,18 @@ pub struct Agent {
7380
pub skills: Vec<String>,
7481
}
7582

83+
/// Whether an agent's transport is runnable today.
84+
#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
85+
#[serde(rename_all = "kebab-case")]
86+
pub enum AgentStatus {
87+
/// Transport binary ships or is installable — the agent can be dispatched to.
88+
#[default]
89+
Available,
90+
/// Declared but not yet runnable (no shipped/installable transport binary).
91+
/// Apps referencing it fail validation/compile rather than at run time (#161).
92+
Planned,
93+
}
94+
7695
/// Declarative authentication for a REST-transport agent.
7796
#[derive(Debug, Deserialize, Clone)]
7897
pub struct AuthScheme {

cli/src/validate.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,47 @@ pub fn validate_app_safety(
261261
out
262262
}
263263

264+
/// Reject nodes that reference an agent the runtime can't dispatch to — one
265+
/// declared `status: planned` (no shipped/installable transport binary). Fails at
266+
/// validate/compile rather than at run with "program not found" (#161). Recurses
267+
/// into `for-each` `do:` bodies. Agents not in the catalogue are skipped here
268+
/// (lockfile resolution / run handle the missing-agent case).
269+
#[allow(dead_code)] // wired by `aware app validate` + `compile_to_disk` + `app run`
270+
pub fn validate_app_agents(
271+
app: &App,
272+
agents: &[crate::manifest::loader::DiscoveredAgent],
273+
) -> Vec<ValidationIssue> {
274+
let mut out = Vec::new();
275+
check_node_agents(&app.nodes, agents, &mut out);
276+
out
277+
}
278+
279+
fn check_node_agents(
280+
nodes: &[crate::manifest::app::Node],
281+
agents: &[crate::manifest::loader::DiscoveredAgent],
282+
out: &mut Vec<ValidationIssue>,
283+
) {
284+
use crate::manifest::agent::AgentStatus;
285+
for n in nodes {
286+
if let Some(agent_id) = &n.agent
287+
&& let Some(d) = agents.iter().find(|d| d.manifest.agent == *agent_id)
288+
&& d.manifest.status == AgentStatus::Planned
289+
{
290+
out.push(ValidationIssue::error(
291+
"E_APP_AGENT_UNAVAILABLE",
292+
format!(
293+
"node {:?} references agent {:?}, which is declared but not yet runnable \
294+
(no shipped/installable transport binary)",
295+
n.id, agent_id
296+
),
297+
));
298+
}
299+
if let Some(body) = &n.do_ {
300+
check_node_agents(body, agents, out);
301+
}
302+
}
303+
}
304+
264305
#[allow(dead_code)] // called by validate_app above
265306
fn has_cycle<'a>(
266307
node: &'a str,
@@ -319,6 +360,49 @@ mod tests {
319360
assert!(!has_errors(&issues), "issues: {issues:?}");
320361
}
321362

363+
fn agent_with_status(status_line: &str) -> crate::manifest::loader::DiscoveredAgent {
364+
let yaml = format!(
365+
"agent: html-report\nversion: 0.1.0\ndescription: x\nstateful: false\n{status_line}\
366+
license: MIT\ntransport:\n cli:\n binary: aware-html-report\ncommands:\n \
367+
render:\n lifecycle: single\n description: x\n"
368+
);
369+
crate::manifest::loader::DiscoveredAgent {
370+
manifest: serde_yaml::from_str(&yaml).unwrap(),
371+
root: std::path::PathBuf::from("."),
372+
}
373+
}
374+
375+
#[test]
376+
fn rejects_node_referencing_planned_agent() {
377+
let agents = vec![agent_with_status("status: planned\n")];
378+
let app: App = serde_yaml::from_str(
379+
"app: uses-planned\nversion: 0.0.1\ndescription: |\n uses planned agent\n\
380+
requires: []\nnodes:\n - id: report\n agent: html-report\n command: render\n",
381+
)
382+
.unwrap();
383+
let issues = validate_app_agents(&app, &agents);
384+
assert!(
385+
issues.iter().any(|i| i.code == "E_APP_AGENT_UNAVAILABLE"),
386+
"issues: {issues:?}"
387+
);
388+
}
389+
390+
#[test]
391+
fn available_agent_passes_agent_validation() {
392+
// No `status:` → defaults to available → no E_APP_AGENT_UNAVAILABLE.
393+
let agents = vec![agent_with_status("")];
394+
let app: App = serde_yaml::from_str(
395+
"app: uses-avail\nversion: 0.0.1\ndescription: |\n uses available agent\n\
396+
requires: []\nnodes:\n - id: report\n agent: html-report\n command: render\n",
397+
)
398+
.unwrap();
399+
let issues = validate_app_agents(&app, &agents);
400+
assert!(
401+
!issues.iter().any(|i| i.code == "E_APP_AGENT_UNAVAILABLE"),
402+
"issues: {issues:?}"
403+
);
404+
}
405+
322406
#[test]
323407
fn rejects_unrunnable_inline_kind_at_validate() {
324408
// kind: shape passes parse but the runtime only runs `predicate`; validate

cli/tests/app_validate.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,37 @@ requires: []
4747
.stdout(predicate::str::contains("E_APP_CYCLE"));
4848
}
4949

50+
#[test]
51+
fn app_referencing_planned_agent_rejected_by_validate() {
52+
// An agent declared `status: planned` (no shipped transport binary) must make
53+
// apps referencing it fail validate, not fail at run with "program not found" (#161).
54+
let home = tempfile::tempdir().unwrap();
55+
let agent_dir = home.path().join("agents").join("html-report");
56+
std::fs::create_dir_all(&agent_dir).unwrap();
57+
std::fs::write(
58+
agent_dir.join("manifest.yaml"),
59+
"agent: html-report\nversion: 0.1.0\ndescription: x\nstateful: false\nstatus: planned\n\
60+
license: MIT\ntransport:\n cli:\n binary: aware-html-report\ncommands:\n render:\n lifecycle: single\n description: x\n",
61+
)
62+
.unwrap();
63+
64+
let appdir = tempfile::tempdir().unwrap();
65+
std::fs::write(
66+
appdir.path().join("report.flo"),
67+
"app: uses-planned\nversion: 0.0.1\ndescription: x\nrequires: []\nnodes:\n - id: report\n agent: html-report\n command: render\n",
68+
)
69+
.unwrap();
70+
71+
Command::cargo_bin("aware")
72+
.unwrap()
73+
.env("AWARE_HOME", home.path())
74+
.args(["app", "validate"])
75+
.arg(appdir.path())
76+
.assert()
77+
.failure()
78+
.stdout(predicate::str::contains("E_APP_AGENT_UNAVAILABLE"));
79+
}
80+
5081
#[test]
5182
fn inline_shape_kind_rejected_by_validate() {
5283
// kind: shape parses + would compile, but the runtime only runs `predicate`;

0 commit comments

Comments
 (0)