harn_hostlib/host_lease/execution.rs
1//! Typed, redacted workload identity for machine-resource lease receipts.
2
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use super::{HostLeasePriorityClass, HostLeaseResourceKey};
9
10const EXECUTION_CONTEXT_SCHEMA_VERSION: u32 = 1;
11
12/// One-way, machine-local identity for a filesystem path.
13///
14/// Receipts can correlate repeated work without publishing a developer's
15/// workspace layout. Callers must canonicalize the path before constructing
16/// the identity when spelling-independent correlation is required.
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub struct HostLeasePathIdentity {
19 /// SHA-256 digest of the platform path encoding.
20 pub sha256: String,
21}
22
23impl HostLeasePathIdentity {
24 /// Construct a redacted identity without persisting the source path.
25 pub fn from_path(path: &Path) -> Self {
26 let digest = Sha256::digest(path.as_os_str().as_encoded_bytes());
27 Self {
28 sha256: hex::encode(digest),
29 }
30 }
31}
32
33/// Operation kind represented by a supervised lease receipt.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "kebab-case")]
36pub enum HostLeaseOperationKind {
37 /// A Cargo build, test, check, or code-generation workload.
38 Cargo,
39}
40
41/// Cargo-specific context nested beneath a generic execution receipt.
42#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
43pub struct HostLeaseCargoExecutionContext {
44 /// Canonical workspace identity supplied to Cargo.
45 pub workspace: HostLeasePathIdentity,
46 /// Isolated final-artifact directory identity.
47 pub target_dir: HostLeasePathIdentity,
48 /// Intermediate-artifact directory identity when explicitly selected.
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub build_dir: Option<HostLeasePathIdentity>,
51}
52
53/// Versioned, redacted execution context stored with a supervised lease.
54///
55/// The tagged enum makes operation-specific fields structurally mandatory;
56/// callers cannot claim a Cargo operation while omitting its Cargo context.
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "operation_kind", rename_all = "kebab-case")]
59pub enum HostLeaseExecutionContext {
60 /// Cargo execution context.
61 Cargo {
62 /// Contract version for this nested receipt.
63 schema_version: u32,
64 /// Redacted Cargo path identities.
65 context: HostLeaseCargoExecutionContext,
66 },
67}
68
69impl HostLeaseExecutionContext {
70 /// Build a Cargo context while keeping raw local paths out of receipts.
71 pub fn cargo(workspace: &Path, target_dir: &Path, build_dir: Option<&Path>) -> Self {
72 Self::Cargo {
73 schema_version: EXECUTION_CONTEXT_SCHEMA_VERSION,
74 context: HostLeaseCargoExecutionContext {
75 workspace: HostLeasePathIdentity::from_path(workspace),
76 target_dir: HostLeasePathIdentity::from_path(target_dir),
77 build_dir: build_dir.map(HostLeasePathIdentity::from_path),
78 },
79 }
80 }
81
82 /// Stable operation kind for scheduling and receipt projections.
83 pub const fn operation_kind(&self) -> HostLeaseOperationKind {
84 match self {
85 Self::Cargo { .. } => HostLeaseOperationKind::Cargo,
86 }
87 }
88
89 /// Cargo context when this receipt represents Cargo work.
90 pub const fn cargo_context(&self) -> Option<&HostLeaseCargoExecutionContext> {
91 match self {
92 Self::Cargo { context, .. } => Some(context),
93 }
94 }
95}
96
97/// Terminal child status recorded by a supervised lease run.
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99pub struct HostLeaseProcessExit {
100 /// Process exit code when the child exited normally.
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub code: Option<i32>,
103 /// Terminating Unix signal, absent on normal and non-Unix exits.
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub signal: Option<i32>,
106}
107
108/// Outcome of releasing a workload lease after its process exits.
109#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "kebab-case")]
111pub enum HostLeaseRunReleaseOutcome {
112 /// The supervisor deleted the matching active lease.
113 Released,
114 /// Another transaction had already recovered the now-dead owner.
115 AlreadyRecovered,
116}
117
118/// Stable category for a failure before workload execution owns a lease.
119#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "kebab-case")]
121pub enum HostLeaseRunStartFailure {
122 /// The supervisor could not encode the worker invocation.
123 WorkerArguments,
124 /// The supervisor could not create the worker process.
125 WorkerSpawn,
126 /// The worker exited before recording lease acquisition or deferral.
127 WorkerExitedBeforeAcquire,
128 /// The worker invocation disagreed with its durable receipt.
129 WorkerContextMismatch,
130 /// The resource store could not complete acquisition.
131 ResourceAcquire,
132 /// An acquired receipt omitted required ownership evidence.
133 WorkerContract,
134 /// The acquired receipt could not advance to running.
135 ReceiptTransition,
136}
137
138/// Stable category for a failure after acquisition but before execution.
139#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "kebab-case")]
141pub enum HostLeaseRunLaunchFailure {
142 /// A process argument could not be represented by the host process API.
143 ArgumentEncoding,
144 /// Replacing the worker with the workload failed.
145 ProcessReplace,
146 /// Creating the workload process failed.
147 ProcessSpawn,
148 /// The platform process-tree guard could not be established.
149 ProcessSupervision,
150 /// The platform has no proven supervised-workload implementation.
151 UnsupportedPlatform,
152}
153
154/// Lifecycle state for one supervised workload.
155#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(tag = "state", rename_all = "kebab-case")]
157pub enum HostLeaseRunState {
158 /// The supervisor persisted intent before creating the worker.
159 Pending {
160 /// Receipt creation time in Unix milliseconds.
161 requested_at_ms: i64,
162 },
163 /// The worker owns the lease and is about to advance the workload.
164 Running {
165 /// Opaque lease token.
166 lease_id: String,
167 /// Lease acquisition time in Unix milliseconds.
168 acquired_at_ms: i64,
169 /// Time spent waiting for the resource.
170 acquire_wait_ms: u64,
171 /// PID whose liveness owns the lease.
172 worker_pid: u32,
173 },
174 /// The resource remained held through the requested wait deadline.
175 Deferred {
176 /// Observation time in Unix milliseconds.
177 observed_at_ms: i64,
178 /// Time spent waiting before deferral.
179 waited_ms: u64,
180 },
181 /// The worker could not reach lease ownership.
182 StartFailed {
183 /// Observation time in Unix milliseconds.
184 observed_at_ms: i64,
185 /// Stable failure category without raw command or path text.
186 error: HostLeaseRunStartFailure,
187 },
188 /// The supervisor cancelled the worker before it acquired the resource.
189 CancelledBeforeStart {
190 /// Terminal observation time in Unix milliseconds.
191 finished_at_ms: i64,
192 },
193 /// Command preparation failed after acquisition but before execution.
194 LaunchFailed {
195 /// Opaque lease token acquired for the failed launch.
196 lease_id: String,
197 /// Lease cleanup result after the launch was abandoned.
198 release: HostLeaseRunReleaseOutcome,
199 /// Observation time in Unix milliseconds.
200 observed_at_ms: i64,
201 /// Stable failure category without raw command or path text.
202 error: HostLeaseRunLaunchFailure,
203 },
204 /// The workload process reached a terminal status.
205 Completed {
206 /// Opaque lease token retained for correlation.
207 lease_id: String,
208 /// Time spent waiting for the resource.
209 acquire_wait_ms: u64,
210 /// Time from acquisition until workload exit.
211 hold_ms: u64,
212 /// PID whose liveness owned the lease.
213 worker_pid: u32,
214 /// Workload exit status.
215 exit: HostLeaseProcessExit,
216 /// Lease cleanup result after the process was confirmed dead.
217 release: HostLeaseRunReleaseOutcome,
218 /// Terminal observation time in Unix milliseconds.
219 finished_at_ms: i64,
220 },
221 /// The supervisor interrupted and reaped the workload process group.
222 Cancelled {
223 /// Opaque lease token retained for correlation.
224 lease_id: String,
225 /// Time spent waiting for the resource.
226 acquire_wait_ms: u64,
227 /// Time from acquisition until cancellation completed.
228 hold_ms: u64,
229 /// PID whose liveness owned the lease.
230 worker_pid: u32,
231 /// Lease cleanup result after process-tree cleanup.
232 release: HostLeaseRunReleaseOutcome,
233 /// Terminal observation time in Unix milliseconds.
234 finished_at_ms: i64,
235 },
236}
237
238impl HostLeaseRunState {
239 pub(super) fn may_transition_to(&self, next: &Self) -> bool {
240 match (self, next) {
241 (
242 Self::Pending { .. },
243 Self::Running { .. }
244 | Self::Deferred { .. }
245 | Self::StartFailed { .. }
246 | Self::CancelledBeforeStart { .. },
247 ) => true,
248 (
249 Self::Running {
250 lease_id,
251 acquire_wait_ms,
252 worker_pid,
253 ..
254 },
255 Self::Completed {
256 lease_id: next_lease_id,
257 acquire_wait_ms: next_wait_ms,
258 worker_pid: next_worker_pid,
259 ..
260 }
261 | Self::Cancelled {
262 lease_id: next_lease_id,
263 acquire_wait_ms: next_wait_ms,
264 worker_pid: next_worker_pid,
265 ..
266 },
267 ) => {
268 lease_id == next_lease_id
269 && acquire_wait_ms == next_wait_ms
270 && worker_pid == next_worker_pid
271 }
272 (
273 Self::Running { lease_id, .. },
274 Self::LaunchFailed {
275 lease_id: next_lease_id,
276 ..
277 },
278 ) => lease_id == next_lease_id,
279 _ => false,
280 }
281 }
282}
283
284/// Durable lifecycle evidence for one supervised workload.
285#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
286pub struct HostLeaseRunReceipt {
287 /// Contract version for this receipt.
288 pub schema_version: u32,
289 /// Stable run identifier and receipt filename stem.
290 pub run_id: String,
291 /// Validated operator or automation identity that requested the run.
292 pub owner: String,
293 /// Scheduling class requested for this run and used by its worker.
294 pub priority_class: HostLeasePriorityClass,
295 /// Maximum resource wait requested by the public supervisor.
296 pub wait_limit_ms: u64,
297 /// Capacity-one machine resource used by the workload.
298 pub resource: HostLeaseResourceKey,
299 /// Typed, redacted workload identity.
300 pub execution_context: HostLeaseExecutionContext,
301 /// Current durable lifecycle state.
302 pub status: HostLeaseRunState,
303}
304
305#[cfg(test)]
306mod tests {
307 use std::path::Path;
308
309 use super::*;
310
311 #[test]
312 fn cargo_context_keeps_only_stable_path_identities() {
313 let context = HostLeaseExecutionContext::cargo(
314 Path::new("/workspace/project"),
315 Path::new("/tmp/target"),
316 Some(Path::new("/tmp/build")),
317 );
318
319 assert_eq!(context.operation_kind(), HostLeaseOperationKind::Cargo);
320 let cargo = context.cargo_context().expect("Cargo context is present");
321 assert_ne!(cargo.target_dir, cargo.build_dir.clone().unwrap());
322 assert_eq!(cargo.workspace.sha256.len(), 64);
323 }
324
325 #[test]
326 fn omitted_build_directory_is_not_invented() {
327 let context = HostLeaseExecutionContext::cargo(
328 Path::new("/workspace/project"),
329 Path::new("/tmp/target"),
330 None,
331 );
332
333 assert!(context
334 .cargo_context()
335 .expect("Cargo context is present")
336 .build_dir
337 .is_none());
338 }
339}