bot_forge/planning/plan.rs
1//! Serializable plan types and target-platform matching.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::schema::{
8 AptMirrorDef, CertificatePreflightDef, CheckSpec, EnvironmentMutation, InstallSpec,
9 NetworkPolicy,
10};
11use crate::error::ForgeError;
12use crate::model::{Agent, InstallKind};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16/// Immutable, hashed description of one installation execution.
17pub struct ExecutionPlan {
18 /// Profile whose closure was resolved.
19 pub profile: String,
20 /// Target platform used for selector matching.
21 pub target: TargetPlatform,
22 /// Digest of the canonical configuration input.
23 pub config_hash: String,
24 /// Digest of this complete plan with the hash field initially empty.
25 pub plan_hash: String,
26 /// Frozen policy consumed by execution and scheduling.
27 pub policy: PlanPolicy,
28 /// Platform-applicable certificate prerequisite executed before ordinary installation work.
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub certificate_preflight: Option<CertificatePreflightDef>,
31 /// Resolved components in deterministic order.
32 pub components: Vec<ResolvedComponent>,
33 /// Components requested by the profile but unavailable on the target platform.
34 ///
35 /// These entries are presentation metadata only. They are intentionally excluded from
36 /// serialized plans and execution graphs so an unsupported component can never be run.
37 #[serde(skip)]
38 pub unsupported_components: Vec<UnsupportedComponent>,
39 /// Executable dependency graph in deterministic order.
40 pub nodes: Vec<ExecutionNode>,
41 /// Platform-applicable environment mutations.
42 pub environment: Vec<EnvironmentMutation>,
43 /// Optional APT mirror configuration carried into execution.
44 pub apt_mirror: Option<AptMirrorDef>,
45 /// Configuration provenance retained for plan explanation.
46 pub origins: BTreeMap<String, String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50/// A profile component retained for display when no target-platform contract matches.
51pub struct UnsupportedComponent {
52 /// Canonical component identifier.
53 pub id: String,
54 /// Optional human-readable component name.
55 pub display_name: Option<String>,
56 /// Tool or skill lifecycle kind.
57 pub kind: InstallKind,
58 /// Whether interactive selection would otherwise allow omission.
59 pub optional: bool,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63/// Resource and policy limits copied into an execution plan.
64pub struct PlanPolicy {
65 /// Network access allowed during execution.
66 pub network: NetworkPolicy,
67 /// Maximum number of concurrently scheduled nodes.
68 pub max_parallel: usize,
69 /// Maximum number of concurrent network transfers.
70 pub max_downloads: usize,
71 /// Optional upper bound on memory tokens, in MiB.
72 pub max_memory_mib: Option<u64>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76/// Operating-system, architecture, and ABI selector used for component matching.
77pub struct TargetPlatform {
78 /// Canonical operating-system name.
79 pub os: String,
80 /// Canonical architecture name.
81 pub arch: String,
82 /// ABI selector, or `native` when the OS contract has no separate ABI dimension.
83 pub abi: String,
84}
85
86impl TargetPlatform {
87 /// Detect the host target using Rust's compile-time platform constants.
88 pub fn host() -> Self {
89 let os = std::env::consts::OS.to_string();
90 let arch = normalize_arch(std::env::consts::ARCH).to_string();
91 let abi = if os == "windows" && cfg!(target_env = "gnu") {
92 "gnu"
93 } else if os == "windows" {
94 "msvc"
95 } else if os == "linux" && cfg!(target_env = "musl") {
96 "musl"
97 } else if os == "linux" {
98 "gnu"
99 } else {
100 "native"
101 };
102 Self {
103 os,
104 arch,
105 abi: abi.to_string(),
106 }
107 }
108
109 /// Return the canonical platform selector used by configuration constraints.
110 pub fn selector(&self) -> String {
111 if self.abi == "native" {
112 format!("{}-{}", self.os, self.arch)
113 } else {
114 format!("{}-{}-{}", self.os, self.arch, self.abi)
115 }
116 }
117
118 /// Reject targets outside the release-supported platform matrix.
119 ///
120 /// # Errors
121 ///
122 /// Returns [`crate::error::ForgeError::Config`] for an unsupported operating-system,
123 /// architecture, or ABI combination.
124 pub fn validate_supported(&self) -> Result<(), ForgeError> {
125 let supported = matches!(
126 (self.os.as_str(), self.arch.as_str(), self.abi.as_str()),
127 ("linux", "x86_64" | "aarch64", "gnu")
128 | ("windows", "x86_64" | "aarch64", "msvc")
129 | ("macos", "x86_64" | "aarch64", "native")
130 );
131 if supported {
132 Ok(())
133 } else {
134 Err(ForgeError::Config(format!(
135 "this release does not support target platform: {}; supported targets are Linux GNU, macOS, and Windows MSVC on x86_64/aarch64",
136 self.selector()
137 )))
138 }
139 }
140
141 /// Test whether a component selector matches this target.
142 pub(crate) fn matches(&self, selector: &str) -> bool {
143 selector == "*"
144 || selector == format!("{}-*", self.os)
145 || selector == self.selector()
146 || selector == format!("{}-{}", self.os, self.arch)
147 }
148}
149
150fn normalize_arch(arch: &str) -> &str {
151 match arch {
152 "amd64" | "x64" => "x86_64",
153 "arm64" => "aarch64",
154 value => value,
155 }
156}
157
158#[cfg(test)]
159mod platform_tests {
160 use crate::planning::TargetPlatform;
161
162 #[test]
163 fn host_abi_matches_the_compilation_target() {
164 let target = TargetPlatform::host();
165 if cfg!(target_os = "linux") && cfg!(target_env = "musl") {
166 assert_eq!(target.abi, "musl");
167 } else if (cfg!(target_os = "linux") || cfg!(windows)) && cfg!(target_env = "gnu") {
168 assert_eq!(target.abi, "gnu");
169 } else if cfg!(windows) {
170 assert_eq!(target.abi, "msvc");
171 } else {
172 assert_eq!(target.abi, "native");
173 }
174 }
175
176 #[test]
177 fn release_contract_rejects_unpublished_target_families() {
178 for target in [
179 TargetPlatform {
180 os: "linux".into(),
181 arch: "x86_64".into(),
182 abi: "musl".into(),
183 },
184 TargetPlatform {
185 os: "windows".into(),
186 arch: "x86_64".into(),
187 abi: "gnu".into(),
188 },
189 ] {
190 assert!(target.validate_supported().is_err());
191 }
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196/// Component after profile, dependency, platform, and variant resolution.
197pub struct ResolvedComponent {
198 /// Canonical component identifier.
199 pub id: String,
200 /// Optional human-readable component name.
201 pub display_name: Option<String>,
202 /// Version supplied by the selected component or platform variant.
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub version: Option<String>,
205 /// Whether interactive selection may omit the component.
206 pub optional: bool,
207 /// Per-component insecure hosts forwarded only to supported installers.
208 pub allow_insecure_hosts: Vec<String>,
209 /// Tool or skill lifecycle kind.
210 pub kind: InstallKind,
211 /// Selected variant identifier, when target resolution chose one.
212 pub variant: Option<String>,
213 /// Stable reasons that pulled the component into the dependency closure.
214 pub requested_by: Vec<String>,
215 /// Canonical component IDs that must complete first.
216 pub dependencies: Vec<String>,
217 /// Capability names supplied by this component.
218 pub provides: Vec<String>,
219 /// Component or capability names that cannot coexist with this selection.
220 pub conflicts: Vec<String>,
221 /// Typed pre-install detection contract.
222 pub detect: Option<CheckSpec>,
223 /// Typed installation contract selected for the target.
224 pub install: Option<InstallSpec>,
225 /// Typed post-install verification contract.
226 pub verify: Option<CheckSpec>,
227 /// Canonical upstream source, when the backend has one.
228 pub source: Option<String>,
229 /// Pinned upstream revision, when the backend has one.
230 pub revision: Option<String>,
231 /// Agent destinations for a skill component.
232 pub agents: Vec<Agent>,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236/// One executable node in the plan DAG.
237pub struct ExecutionNode {
238 /// Stable node identity referenced by dependency edges.
239 pub id: String,
240 /// Resolved component associated with this node.
241 pub component: String,
242 /// Lifecycle operation represented by the node.
243 pub kind: NodeKind,
244 /// Node IDs that must complete successfully before this node can run.
245 pub dependencies: Vec<String>,
246 /// Capacities and named locks acquired before running the node.
247 pub resources: Vec<ResourceClaim>,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "kebab-case")]
252/// Lifecycle operation performed by an execution node.
253pub enum NodeKind {
254 /// Observe whether a component already satisfies its detection contract.
255 Detect,
256 /// Fetch network-backed input into managed local state.
257 Fetch,
258 /// Perform the component's typed installation transaction.
259 Acquire,
260 /// Validate the installed result against its verification contract.
261 Verify,
262 /// Persist a verified immutable artifact.
263 Store,
264 /// Switch a managed target to the selected artifact.
265 Activate,
266 /// Commit installation metadata to persistent state.
267 Record,
268 /// Apply selected environment mutations.
269 Environment,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273/// Quantity of a named capacity or lock resource required by a node.
274pub struct ResourceClaim {
275 /// Named capacity or lock key.
276 pub key: String,
277 /// Number of units required atomically.
278 pub units: u32,
279}