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