1use std::{ffi::OsString, path::Path};
2
3use anyhow::{bail, Context, Result};
4use serde::Deserialize;
5
6use crate::{
7 adapter::adapter_for_plan,
8 configuration,
9 crew::{self, CrewEvidence},
10 embedding, emission,
11 model::{ModelArtifactState, Plan, ShareState, State, ToolState},
12};
13
14#[derive(Deserialize)]
15struct ImportPromotion {
16 eligible: bool,
17 blockers: Vec<String>,
18}
19
20#[derive(Deserialize)]
21struct ImportResult {
22 outcome: String,
23 share_id: String,
24 staging_graph: String,
25 promotion: ImportPromotion,
26}
27
28#[derive(Debug, PartialEq, Eq)]
36pub(crate) enum Convergence {
37 Current,
39 Converge,
41 Decline,
45 RefuseSkipInstall,
49}
50
51pub(crate) fn decide_convergence(
52 installed: &str,
53 desired: &str,
54 is_current: bool,
55 skip_install: bool,
56) -> Convergence {
57 if is_current {
58 return Convergence::Current;
59 }
60 if skip_install {
61 return Convergence::RefuseSkipInstall;
62 }
63 match crate::release_update::behind_reviewed(installed, desired) {
64 Some(true) => Convergence::Converge,
65 Some(false) | None => Convergence::Decline,
68 }
69}
70
71pub fn apply(plan: &Plan, state_path: &Path, skip_install: bool) -> Result<State> {
72 plan.validate()?;
73 let mut state = State::read(state_path)?;
74 let mut failures = Vec::new();
75 for &name in &plan.tools {
76 if name == crate::model::ToolName::Quipu && plan.external_quipu {
77 state.tools.remove(name.as_str());
79 state.write(state_path)?;
80 }
81 let adapter = adapter_for_plan(name, plan)?;
82 let desired = adapter.desired_version();
83 let version = (|| -> Result<String> {
94 Ok(match adapter.version() {
95 Ok(installed) if adapter.is_current(&installed) => installed,
96
97 Ok(installed) => {
101 match decide_convergence(&installed, &desired, false, skip_install) {
102 Convergence::Current => installed,
103 Convergence::RefuseSkipInstall => bail!(
104 "{} is installed at {installed} but this Tambo build reviewed {desired}; \
105 --skip-install refuses to converge it. Install {desired}, or drop \
106 --skip-install to let apply converge it.",
107 name.as_str()
108 ),
109 Convergence::Decline => {
110 println!(
111 "{}: NOT converged — installed {installed}, reviewed {desired}. \
112 Apply does not downgrade or replace an unrecognised version; \
113 use `tambo update-release` deliberately if that is what you want.",
114 name.as_str()
115 );
116 installed
117 }
118 Convergence::Converge => {
119 eprintln!(
120 "{}: stale ({installed}); converging to reviewed {desired}",
121 name.as_str()
122 );
123 adapter.install().with_context(|| {
124 format!("{} convergence to reviewed {desired}", name.as_str())
125 })?;
126 let after = adapter.version().with_context(|| {
127 format!("{} version read-back after convergence", name.as_str())
128 })?;
129 if !adapter.is_current(&after) {
131 bail!(
132 "{} did not reach reviewed release {desired} (got {after}); \
133 refusing to record it as applied",
134 name.as_str()
135 );
136 }
137 println!("{}: converged {installed} -> {after}", name.as_str());
138 after
139 }
140 }
141 }
142
143 Err(error)
144 if !skip_install
145 && !(name == crate::model::ToolName::Quipu && plan.external_quipu) =>
146 {
147 eprintln!("{}: not installed ({error:#}); installing", name.as_str());
148 adapter
149 .install()
150 .with_context(|| {
151 format!(
152 "{} install step (run `tambo doctor` to list every blocker on this host)",
153 name.as_str()
154 )
155 })?;
156 adapter.version().with_context(|| {
157 format!("{} version read-back after install", name.as_str())
158 })?
159 }
160 Err(error) => {
161 return Err(error).context(format!("{} version read-back", name.as_str()))
162 }
163 })
164 })();
165 let version = version.and_then(|version| {
170 if name == crate::model::ToolName::Quipu && !plan.external_quipu {
171 if skip_install {
174 crate::adapter::verify_existing_quipu_alias()?;
175 } else {
176 crate::adapter::ensure_existing_quipu_alias()?;
177 }
178 }
179 if !(name == crate::model::ToolName::Quipu && plan.external_quipu) {
180 check_path_resolution(name)?;
181 }
182 Ok(version)
183 });
184 let version = match version {
185 Ok(version) => version,
186 Err(error) => {
187 state.tools.remove(name.as_str());
190 state.write(state_path)?;
191 eprintln!("{}: FAILED: {error:#}", name.as_str());
192 failures.push(format!("{}: {error:#}", name.as_str()));
193 continue;
194 }
195 };
196 let remains_verified = state
197 .tools
198 .get(name.as_str())
199 .is_some_and(|previous| previous.version == version && previous.verified);
200 state.tools.insert(
201 name.as_str().to_owned(),
202 ToolState {
203 version,
204 applied: true,
205 verified: remains_verified,
206 },
207 );
208 state.write(state_path)?;
209 emission::queue_transition(
210 state_path,
211 name.as_str(),
212 "applied",
213 &state.tools[name.as_str()].version,
214 )?;
215 println!("{}: applied", name.as_str());
216 }
217 if !failures.is_empty() {
218 bail!(
219 "{} tool(s) failed to apply:\n{}\nSuccessful tools were saved. Fix the failures and rerun the same command. \
220 Stack configuration, model provisioning, crew setup, share imports and verification \
221 have not run; run `tambo doctor` for preflight guidance.",
222 failures.len(),
223 failures.join("\n")
224 );
225 }
226 if let Some(config) = &plan.stack_config {
227 configuration::apply(config)?;
228 println!("stack configuration: applied");
229 }
230 if let Some(model) = &plan.embedding_model {
231 let provisioned = embedding::provision(model, crate::adapter::download_https)
232 .context("embedding-model provisioning step")?;
233 for artifact in provisioned {
234 let remains_verified = state
237 .models
238 .get(&artifact.name)
239 .is_some_and(|previous| previous.sha256 == artifact.sha256 && previous.verified);
240 state.models.insert(
241 artifact.name.clone(),
242 ModelArtifactState {
243 path: artifact.path.clone(),
244 sha256: artifact.sha256.clone(),
245 provisioned: true,
246 verified: remains_verified,
247 },
248 );
249 state.write(state_path)?;
250 emission::queue_transition(
251 state_path,
252 &format!("embedding-model/{}", artifact.name),
253 "applied",
254 &artifact.sha256,
255 )?;
256 let outcome = match artifact.outcome {
257 embedding::ArtifactOutcome::Fetched => "provisioned",
258 embedding::ArtifactOutcome::Current => "current",
259 };
260 println!("embedding-model {}: {outcome}", artifact.name);
261 }
262 }
263 #[cfg(unix)]
264 if let Some(mcp) = &plan.quipu_mcp {
265 let outcome = crate::quipu_mcp::provision(&mcp.url, mcp.token_file.as_deref())
266 .context("quipu MCP registration step (headersHelper)")?;
267 println!("quipu mcp: {outcome}");
268 }
269 if let Some(selection) = &plan.crew {
270 crew::apply(selection, &mut state, skip_install)?;
271 state.write(state_path)?;
272 for (name, runtime) in &state.crew {
273 emission::queue_transition(state_path, name, "applied", &runtime.version)?;
274 }
275 }
276 let resolved = crate::tool_bundles::resolve(&crate::hook_bundles::selected(
280 plan.tools.iter().map(|t| t.as_str()),
281 ));
282 for (name, _, source) in &resolved {
283 println!("hook bundle {name}: source {}", source.label());
284 }
285 for line in crate::hook_bundles::register(&crate::tool_bundles::as_pairs(&resolved), "st")? {
286 println!("hook bundle {line}");
287 }
288 if crate::dp_alias::wanted(plan.tools.iter().map(|t| t.as_str())) {
291 let outcome = crate::dp_alias::apply(crate::dp_alias::locate().as_deref(), None)
292 .context("Desire Path bd -> sd alias step")?;
293 println!("dp alias {}", outcome.line());
294 }
295 load_vocabulary(plan)?;
296 consume_shares(plan, &mut state, state_path)?;
297 Ok(state)
298}
299
300fn load_vocabulary(plan: &Plan) -> Result<()> {
305 if !plan.tools.contains(&crate::model::ToolName::Quipu) {
306 return Ok(());
307 }
308 let cached = crate::vocabulary::ensure_cached()?;
309 match plan.quipu_db.as_deref() {
310 Some(db) => {
311 crate::vocabulary::load(db)?;
312 println!(
313 "vocabulary: quechua v{} loaded into {}",
314 crate::vocabulary::QUECHUA_VERSION,
315 db.display()
316 );
317 }
318 None => println!(
319 "vocabulary: quechua v{} cached at {} (no plan quipu_db to load into)",
320 crate::vocabulary::QUECHUA_VERSION,
321 cached.display()
322 ),
323 }
324 Ok(())
325}
326
327fn consume_shares(plan: &Plan, state: &mut State, state_path: &Path) -> Result<()> {
328 let Some(db) = plan.quipu_db.as_deref() else {
329 return Ok(());
330 };
331 for share in &plan.shares {
332 let result = crate::adapter::checked(
333 "quipu",
334 [
335 OsString::from("import"),
336 share.as_os_str().to_owned(),
337 OsString::from("--db"),
338 db.as_os_str().to_owned(),
339 ],
340 None,
341 )
342 .with_context(|| format!("import canonical Quipu share {}", share.display()))?;
343 let imported: ImportResult = serde_json::from_slice(&result.stdout)
344 .with_context(|| format!("parse Quipu import result for {}", share.display()))?;
345 if !matches!(
346 imported.outcome.as_str(),
347 "staged" | "quarantined" | "unchanged"
348 ) {
349 bail!(
350 "Quipu returned unknown share import outcome {:?} for {}",
351 imported.outcome,
352 share.display()
353 );
354 }
355 state.shares.insert(
356 imported.share_id.clone(),
357 ShareState {
358 path: share.clone(),
359 staging_graph: imported.staging_graph,
360 outcome: imported.outcome.clone(),
361 promotion_eligible: imported.promotion.eligible,
362 blockers: imported.promotion.blockers,
363 },
364 );
365 state.write(state_path)?;
366 println!(
367 "share {}: {} (promotion eligible: {})",
368 imported.share_id, imported.outcome, imported.promotion.eligible
369 );
370 }
371 Ok(())
372}
373
374fn check_path_resolution(name: crate::model::ToolName) -> Result<()> {
378 let path = std::env::var_os("PATH");
379 for &program in crate::adapter::programs(name) {
380 match crate::adapter::path_resolution(program, path.as_deref()) {
381 crate::adapter::PathResolution::Managed => {}
382 crate::adapter::PathResolution::NotOnPath => println!(
383 "{}: note: `{program}` is not on PATH; add the install directory to PATH to run it",
384 name.as_str()
385 ),
386 crate::adapter::PathResolution::SameBuild { runs, managed } => println!(
387 "{}: note: PATH runs {} rather than {}, but both report the same version",
388 name.as_str(),
389 runs.display(),
390 managed.display()
391 ),
392 crate::adapter::PathResolution::Shadowed {
393 runs,
394 runs_version,
395 managed,
396 managed_version,
397 } => anyhow::bail!(
398 "{program} is SHADOWED: PATH runs {} ({}), not the verified {} ({}). \
399 Remove the stale copy or put {} earlier on PATH",
400 runs.display(),
401 runs_version.trim(),
402 managed.display(),
403 managed_version.trim(),
404 managed
405 .parent()
406 .map(|d| d.display().to_string())
407 .unwrap_or_default()
408 ),
409 }
410 }
411 Ok(())
412}
413
414pub fn verify(plan: &Plan, state_path: &Path, evidence: &CrewEvidence) -> Result<State> {
415 plan.validate()?;
416 let mut state = State::read(state_path)?;
417 for line in hook_bundles_verify(plan)? {
423 println!("hook bundle {line}");
424 }
425 if plan.tools.contains(&crate::model::ToolName::Quipu) && plan.requires_remote_quipu() {
426 if let Some(record) = state.tools.get_mut("quipu") {
427 record.verified = false;
428 state.write(state_path)?;
429 }
430 let server = std::env::var("QUIPU_SERVER")
431 .ok()
432 .filter(|url| !url.trim().is_empty())
433 .unwrap_or_else(|| {
434 plan.quipu_mcp
435 .as_ref()
436 .map(|mcp| mcp.url.clone())
437 .unwrap_or_else(|| "http://localhost:3030".to_owned())
438 });
439 let source = crate::quipu_credential::verify(&server)
440 .context("live Quipu client credential verification")?;
441 println!("quipu credential: canonical file 0400/0700 and authenticated read verified; source: {source}");
442 }
443 for &name in &plan.tools {
444 if name == crate::model::ToolName::Quipu && plan.external_quipu {
445 state.tools.remove(name.as_str());
447 state.write(state_path)?;
448 }
449 let adapter = adapter_for_plan(name, plan)?;
450 let version = adapter
451 .version()
452 .with_context(|| format!("{} version read-back", name.as_str()))?;
453 adapter.verify().with_context(|| {
460 let desired = adapter.desired_version();
461 if adapter.is_current(&version) {
462 format!("{} functional verification", name.as_str())
463 } else {
464 format!(
465 "{} functional verification — VERSION SKEW: {version} is installed, \
466 this Tambo build reviewed and verifies against {desired}. The error \
467 below is most likely that skew, not a broken tool. Converge with \
468 `tambo apply` (or `tambo update`), then re-verify.",
469 name.as_str()
470 )
471 }
472 })?;
473 if !(name == crate::model::ToolName::Quipu && plan.external_quipu) {
474 check_path_resolution(name)?;
475 }
476 state.tools.insert(
477 name.as_str().to_owned(),
478 ToolState {
479 version,
480 applied: true,
481 verified: true,
482 },
483 );
484 state.write(state_path)?;
485 emission::queue_transition(
486 state_path,
487 name.as_str(),
488 "verified",
489 &state.tools[name.as_str()].version,
490 )?;
491 println!("{}: verified", name.as_str());
492 }
493 if crate::dp_alias::wanted(plan.tools.iter().map(|t| t.as_str())) {
495 let outcome = crate::dp_alias::verify(crate::dp_alias::locate().as_deref(), None)
496 .context("Desire Path bd -> sd alias")?;
497 println!("dp alias {}", outcome.line());
498 }
499 if let Some(model) = &plan.embedding_model {
500 embedding::verify(model).context("embedding-model artifact re-hash step")?;
503 for artifact in &model.artifacts {
504 state.models.insert(
505 artifact.name.clone(),
506 ModelArtifactState {
507 path: model.destination.join(&artifact.name),
508 sha256: artifact.sha256.clone(),
509 provisioned: true,
510 verified: true,
511 },
512 );
513 state.write(state_path)?;
514 emission::queue_transition(
515 state_path,
516 &format!("embedding-model/{}", artifact.name),
517 "verified",
518 &artifact.sha256,
519 )?;
520 println!("embedding-model {}: digest re-checked", artifact.name);
521 }
522 }
523 #[cfg(unix)]
524 if let Some(mcp) = &plan.quipu_mcp {
525 let notes = crate::quipu_mcp::verify(&mcp.url)
526 .context("quipu MCP authenticated-write probe (headersHelper)")?;
527 for note in notes {
528 println!("quipu mcp: {note}");
529 }
530 println!("quipu mcp: authenticated MCP write reached Quipu's parser; unauthenticated control was refused");
531 }
532 if let Some(selection) = &plan.crew {
533 crew::verify(selection, evidence, &mut state)?;
534 state.write(state_path)?;
535 for (name, runtime) in &state.crew {
536 emission::queue_transition(state_path, name, "verified", &runtime.version)?;
537 }
538 }
539 Ok(state)
540}
541
542pub(crate) fn reviewed_drift_status(installed: &str, desired: &str) -> &'static str {
545 match crate::release_update::behind_reviewed(installed, desired) {
546 Some(true) => "update available",
547 Some(false) if crate::release_update::behind_reviewed(desired, installed) == Some(true) => {
548 "ahead of reviewed pin; downgrade refused"
549 }
550 Some(false) | None => "version drift; review required",
551 }
552}
553
554fn package_owned_status(name: crate::model::ToolName, desired: &str) -> Option<(String, bool)> {
558 use crate::package::{apt_receipt, primary_program, standing, Standing};
559 let receipt = primary_program(name)
560 .and_then(|program| apt_receipt(&program))
561 .or_else(|| crate::package::brew_receipt(name))?;
562 let status = standing(&receipt.version, desired);
563 let verdict = match status {
564 Standing::Current => "current",
565 Standing::Ahead => "ahead of reviewed pin; downgrade refused",
566 Standing::Behind => {
567 "behind reviewed pin; delivered by the package lane, not installed by caboodle"
568 }
569 Standing::Unknown => "version drift; review required",
570 };
571 Some((
572 format!(
573 "{}: {verdict} (package-owned: {} {} {}; reviewed: {desired})",
574 name.as_str(),
575 receipt.manager,
576 receipt.package,
577 receipt.version
578 ),
579 status == Standing::Current,
580 ))
581}
582
583fn brew_update(
589 name: crate::model::ToolName,
590 adapter: &dyn crate::adapter::Adapter,
591) -> Result<Option<String>> {
592 use crate::package::{
593 brew_channel, brew_converge, brew_formula, brew_program, standing, Standing,
594 };
595 if brew_formula(name).is_none() || brew_program().is_none() {
596 return Ok(None);
597 }
598 let Some(channel) = brew_channel(name)? else {
599 return Ok(None);
600 };
601 let desired = adapter.desired_version();
602 let stable = format!("pkg {}", channel.stable);
603 if channel.pinned {
604 let installed = channel
605 .installed
606 .context("pinned brew formula has no installed receipt")?;
607 println!(
608 "{}: pinned Homebrew formula; update skipped ({} {}; stable: {})",
609 name.as_str(),
610 crate::package::STACK_TAP,
611 installed,
612 channel.stable
613 );
614 return brew_version(adapter, &installed).map(Some);
615 }
616 match channel.installed {
617 Some(installed) => match standing(&installed, &stable) {
618 Standing::Behind => {
619 if standing(&channel.stable, &desired) == Standing::Behind {
622 return Ok(None);
623 }
624 brew_converge(name, true)?;
625 }
626 Standing::Current | Standing::Ahead => {
627 let word = if standing(&installed, &stable) == Standing::Ahead {
628 "ahead of stable channel; downgrade refused"
629 } else {
630 "current stable channel"
631 };
632 println!(
633 "{}: {word} (package-owned: brew {} {}; stable: {})",
634 name.as_str(),
635 crate::package::STACK_TAP,
636 installed,
637 channel.stable
638 );
639 return brew_version(adapter, &installed).map(Some);
640 }
641 Standing::Unknown => {
642 anyhow::bail!("{} brew versions cannot be compared", name.as_str())
643 }
644 },
645 None => {
646 if standing(&channel.stable, &desired) == Standing::Behind {
647 return Ok(None);
648 }
649 if let Ok(installed) = adapter.version() {
650 if crate::release_update::behind_reviewed(&stable, &installed) == Some(true) {
651 println!(
652 "{}: installed {installed} is newer than {} {}; not installed by brew",
653 name.as_str(),
654 crate::package::STACK_TAP,
655 channel.stable
656 );
657 return Ok(Some(installed));
658 }
659 }
660 brew_converge(name, false)?;
661 }
662 }
663 let receipt = brew_channel(name)?
664 .context("brew channel after delivery")?
665 .installed
666 .context("brew receipt after delivery")?;
667 if !matches!(
668 standing(&receipt, &stable),
669 Standing::Current | Standing::Ahead
670 ) {
671 anyhow::bail!(
672 "{} brew update did not reach stable {} (receipt: {receipt})",
673 name.as_str(),
674 channel.stable
675 );
676 }
677 let package_adapter = adapter.package_adapter()?;
679 let adapter = package_adapter.as_deref().unwrap_or(adapter);
680 adapter
681 .verify()
682 .with_context(|| format!("{} verification after brew delivery", name.as_str()))?;
683 let version = brew_version(adapter, &receipt)?;
684 println!(
685 "{}: delivered by brew ({} {}) and verified",
686 name.as_str(),
687 crate::package::STACK_TAP,
688 receipt
689 );
690 Ok(Some(version))
691}
692
693fn brew_version(adapter: &dyn crate::adapter::Adapter, receipt: &str) -> Result<String> {
694 let package_adapter = adapter.package_adapter()?;
697 let adapter = package_adapter.as_deref().unwrap_or(adapter);
698 let version = adapter
699 .version()
700 .context("version read-back after brew delivery")?;
701 let identities: Vec<_> = if adapter.name() == crate::model::ToolName::Quipu {
705 version.split(';').take(2).collect()
706 } else {
707 vec![version.as_str()]
708 };
709 if identities.iter().any(|identity| {
710 crate::package::standing(receipt, identity.trim()) != crate::package::Standing::Current
711 }) {
712 anyhow::bail!("brew receipt and executable version disagree");
713 }
714 Ok(version)
715}
716
717pub fn check_updates(plan: &Plan) -> Result<bool> {
718 plan.validate()?;
719 let mut current = true;
720 for &name in &plan.tools {
721 let adapter = adapter_for_plan(name, plan)?;
722 let desired = adapter.desired_version();
723 if name == crate::model::ToolName::Quipu && plan.external_quipu {
724 println!(
725 "quipu: externally owned; release convergence not assessed ({})",
726 adapter.version()?
727 );
728 continue;
729 }
730 if let Some((line, is_current)) = package_owned_status(name, &desired) {
731 current &= is_current;
732 println!("{line}");
733 continue;
734 }
735 match adapter.version() {
736 Ok(installed) if adapter.is_current(&installed) => {
737 println!("{}: current ({installed})", name.as_str());
738 }
739 Ok(installed) => {
740 current = false;
741 println!(
742 "{}: {} (installed: {installed}; reviewed: {desired})",
743 name.as_str(),
744 reviewed_drift_status(&installed, &desired)
745 );
746 }
747 Err(error) => {
748 current = false;
749 println!(
750 "{}: missing or unreadable ({error:#}); reviewed: {desired}",
751 name.as_str()
752 );
753 }
754 }
755 }
756 if let Some(selection) = &plan.crew {
757 current &= crew::check_updates(selection);
758 }
759 Ok(current)
760}
761
762pub fn update(plan: &Plan, state_path: &Path, evidence: &CrewEvidence) -> Result<State> {
765 plan.validate()?;
766 let mut state = State::read(state_path)?;
767 if crate::package::brew_program().is_some()
768 && plan.tools.iter().any(|name| {
769 crate::package::brew_formula(*name).is_some()
770 && !(*name == crate::model::ToolName::Quipu && plan.external_quipu)
771 })
772 {
773 crate::package::brew_refresh()?;
774 }
775 for &name in &plan.tools {
776 let adapter = adapter_for_plan(name, plan)?;
777 if name == crate::model::ToolName::Quipu && plan.external_quipu {
778 println!(
779 "quipu: externally owned; update skipped ({})",
780 adapter.version()?
781 );
782 continue;
783 }
784 if let Some(version) = brew_update(name, adapter.as_ref())? {
785 state.tools.insert(
786 name.as_str().to_owned(),
787 ToolState {
788 version: version.clone(),
789 applied: true,
790 verified: true,
791 },
792 );
793 state.write(state_path)?;
794 emission::queue_transition(state_path, name.as_str(), "updated", &version)?;
795 continue;
796 }
797 if let Some((line, _)) = package_owned_status(name, &adapter.desired_version()) {
798 println!("{line}");
799 continue;
800 }
801 let before = adapter.version().ok();
802 if before
803 .as_deref()
804 .is_some_and(|installed| adapter.is_current(installed))
805 {
806 if name == crate::model::ToolName::Quipu {
807 crate::adapter::ensure_existing_quipu_alias()?;
808 }
809 println!("{}: current", name.as_str());
810 continue;
811 }
812 #[cfg(unix)]
813 crate::release_update::guard_reviewed_update(name, &adapter.desired_version())?;
814 eprintln!(
815 "{}: converging {:?} -> {}",
816 name.as_str(),
817 before,
818 adapter.desired_version()
819 );
820 adapter
821 .install()
822 .with_context(|| format!("{} reviewed update install", name.as_str()))?;
823 let version = adapter
824 .version()
825 .with_context(|| format!("{} version read-back after update", name.as_str()))?;
826 if !adapter.is_current(&version) {
827 bail!(
828 "{} update did not reach reviewed release {} (got {})",
829 name.as_str(),
830 adapter.desired_version(),
831 version
832 );
833 }
834 adapter
835 .verify()
836 .with_context(|| format!("{} verification after update", name.as_str()))?;
837 state.tools.insert(
838 name.as_str().to_owned(),
839 ToolState {
840 version: version.clone(),
841 applied: true,
842 verified: true,
843 },
844 );
845 state.write(state_path)?;
846 emission::queue_transition(state_path, name.as_str(), "updated", &version)?;
847 println!("{}: updated and verified", name.as_str());
848 }
849 if let Some(selection) = &plan.crew {
850 if !crew::check_updates(selection) {
851 crew::apply(selection, &mut state, false)?;
852 crew::verify(selection, evidence, &mut state)?;
853 for line in hook_bundles_verify(plan)? {
854 println!("hook bundle {line}");
855 }
856 state.write(state_path)?;
857 for (name, runtime) in &state.crew {
858 emission::queue_transition(state_path, name, "updated", &runtime.version)?;
859 }
860 }
861 }
862 Ok(state)
863}
864
865fn hook_bundles_verify(plan: &Plan) -> Result<Vec<String>> {
866 let resolved = crate::tool_bundles::resolve(&crate::hook_bundles::selected(
867 plan.tools.iter().map(|t| t.as_str()),
868 ));
869 crate::hook_bundles::verify(&crate::tool_bundles::as_pairs(&resolved))
870}
871
872pub fn verify_questions(plan: &Plan, db: Option<&Path>) -> Result<()> {
873 plan.validate()?;
874 let intent = plan.intent.as_ref().context(
875 "plan has no intended-use/question contract; regenerate it through the Phase 2 interview",
876 )?;
877 for (index, contract) in intent.anticipated_questions.iter().enumerate() {
878 let scratch = if contract.is_self_test() {
881 Some(seed_self_test_store()?)
882 } else {
883 None
884 };
885 let db = scratch.as_ref().map(|(_, db)| db.as_path()).or(db);
886 let mut args = vec![OsString::from("read"), OsString::from(&contract.sparql)];
887 if let Some(db) = db {
888 args.push(OsString::from("--db"));
889 args.push(db.as_os_str().to_owned());
890 }
891 let result = crate::adapter::checked("quipu", args, None)
892 .with_context(|| format!("anticipated question {} query", index + 1))?;
893 let answer = String::from_utf8_lossy(&result.stdout);
894 if !answer.contains(&contract.expected) {
895 anyhow::bail!(
896 "anticipated question {} was executable but its answer did not contain {:?}: {}",
897 index + 1,
898 contract.expected,
899 contract.question
900 );
901 }
902 println!("question {}: verified — {}", index + 1, contract.question);
903 }
904 Ok(())
905}
906
907fn seed_self_test_store() -> Result<(tempfile::TempDir, std::path::PathBuf)> {
910 use crate::model::SELF_TEST_MARKER;
911 let dir = tempfile::tempdir().context("create self-test question store")?;
912 let db = dir.path().join("self-test.db");
913 let episode = dir.path().join("episode.json");
914 std::fs::write(
915 &episode,
916 format!(
917 r#"{{"name":"caboodle self-test question","episode_body":"self-test seed","source":"caboodle","group_id":"caboodle-verification","nodes":[{{"name":"{SELF_TEST_MARKER}","type":"Verification","description":"caboodle self-test question seed"}}],"edges":[]}}"#
918 ),
919 )
920 .context("write self-test episode")?;
921 crate::adapter::checked(
922 "quipu",
923 [
924 OsString::from("episode"),
925 episode.into_os_string(),
926 OsString::from("--db"),
927 db.clone().into_os_string(),
928 ],
929 None,
930 )
931 .context("seed the self-test question store")?;
932 Ok((dir, db))
933}
934
935pub fn provision_quipu_credential(source: &Path, server: &str) -> Result<()> {
937 let home = std::env::var_os("HOME").context("HOME required for canonical Quipu credential")?;
938 crate::quipu_credential::provision(source, server, Path::new(&home))?;
939 println!(
940 "quipu credential: issued file accepted and canonical installation verified (0400/0700)"
941 );
942 Ok(())
943}
944
945#[cfg(test)]
946mod convergence_tests {
947 use super::{decide_convergence, reviewed_drift_status, Convergence};
948
949 const REVIEWED: &str = "bobbin 0.16.2";
950
951 #[test]
952 fn reviewed_drift_never_proposes_a_downgrade_or_unknown_replacement() {
953 assert_eq!(
954 reviewed_drift_status("sd 0.1.4 (seeds)", "seeds 0.0.4"),
955 "ahead of reviewed pin; downgrade refused"
956 );
957 assert_eq!(
958 reviewed_drift_status("sd 0.0.3", "seeds 0.0.4"),
959 "update available"
960 );
961 for installed in ["sd dev", "sd 0.0.4", "sd 0.0.4-rc1"] {
962 assert_eq!(
963 reviewed_drift_status(installed, "seeds 0.0.4"),
964 "version drift; review required"
965 );
966 }
967 }
968
969 #[test]
970 fn current_is_left_alone_even_under_skip_install() {
971 assert_eq!(
974 decide_convergence(REVIEWED, REVIEWED, true, true),
975 Convergence::Current
976 );
977 assert_eq!(
978 decide_convergence(REVIEWED, REVIEWED, true, false),
979 Convergence::Current
980 );
981 }
982
983 #[test]
984 fn stale_converges_and_skip_install_refuses_instead_of_claiming_applied() {
985 assert_eq!(
986 decide_convergence("bobbin 0.1.0", REVIEWED, false, false),
987 Convergence::Converge
988 );
989 assert_eq!(
990 decide_convergence("bobbin 0.1.0", REVIEWED, false, true),
991 Convergence::RefuseSkipInstall
992 );
993 }
994
995 #[test]
996 fn ahead_or_incomparable_is_declined_not_replaced() {
997 assert_eq!(
998 decide_convergence("bobbin 0.99.0", REVIEWED, false, false),
999 Convergence::Decline
1000 );
1001 assert_eq!(
1002 decide_convergence("bobbin dev", REVIEWED, false, false),
1003 Convergence::Decline
1004 );
1005 }
1006}