use std::{
collections::BTreeMap,
fs::{self, File, OpenOptions},
io::{Read, Write},
path::{Path, PathBuf},
time::Duration,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::{ExecutionContext, interface::CliError};
use shepherd::RunState;
use shepherd::run::RunStatus;
pub(crate) const REGISTRY_FILE: &str = "native-orientation-registry.json";
const LOCK_FILE: &str = "native-orientation-registry.lock";
const REGISTRY_SCHEMA: &str = "shepherd.native-orientation-registry/1";
const POLICY: &str = "v657-planning";
const MAX_BYTES: u64 = 256 * 1024;
const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
struct Registry {
schema: String,
policy: String,
project_root: String,
runs: BTreeMap<String, RunAuthority>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RunAuthority {
pub(crate) run: String,
pub(crate) project_id: Option<String>,
pub(crate) run_incarnation: String,
pub(crate) orientation_epoch: u64,
pub(crate) initialized_at: i64,
pub(crate) pre: Option<PreCheckpoint>,
pub(crate) post: Option<PostCheckpoint>,
#[serde(default)]
pub(crate) pre_history: Vec<RetiredPreCheckpoint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) legacy_adoption_sha256: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct PreCheckpoint {
pub(crate) epoch: u64,
pub(crate) created_at: i64,
pub(crate) run_revision: u64,
pub(crate) manifest_sha256: String,
pub(crate) pre_sha256: String,
pub(crate) engineer_nonce: String,
#[serde(default)]
pub(crate) input_archive: Option<ArchivedInput>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ArchivedInput {
pub(crate) path: String,
pub(crate) sha256: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RetiredPreCheckpoint {
pub(crate) pre: PreCheckpoint,
pub(crate) post: Option<PostCheckpoint>,
pub(crate) replaced_agent_id: String,
pub(crate) replacement_agent_id: String,
pub(crate) replacement_nonce: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct PostCheckpoint {
pub(crate) epoch: u64,
pub(crate) created_at: i64,
pub(crate) run_revision: u64,
pub(crate) post_sha256: String,
pub(crate) critic_nonce: String,
#[serde(default)]
pub(crate) input_archive: Option<ArchivedInput>,
}
pub(crate) fn registry_path(context: &ExecutionContext) -> PathBuf {
context.namespace.join(REGISTRY_FILE)
}
pub(crate) fn registry_lock_path(context: &ExecutionContext) -> PathBuf {
context.namespace.join(LOCK_FILE)
}
pub(crate) fn new_incarnation() -> String {
Uuid::now_v7().simple().to_string()
}
pub(crate) fn new_nonce() -> String {
format!("{}{}", Uuid::now_v7().simple(), Uuid::now_v7().simple())
}
pub(crate) fn register_run(
context: &ExecutionContext,
run: &str,
now: i64,
) -> Result<RunAuthority, CliError> {
let project_root = canonical(&context.primary_root)?;
let project_id = crate::cmd::dispatch::read_project_id(&context.project_id_path)
.ok()
.map(|value| value.to_string());
with_registry(context, true, |registry| {
if registry.project_root != project_root {
return Err(error(
"native orientation registry belongs to another project root",
));
}
if registry.runs.contains_key(run) {
return Err(error(format!(
"run incarnation for `{run}` already exists in native registry"
)));
}
let authority = RunAuthority {
run: run.into(),
project_id,
run_incarnation: new_incarnation(),
orientation_epoch: 0,
initialized_at: now,
pre: None,
post: None,
pre_history: Vec::new(),
legacy_adoption_sha256: None,
};
registry.runs.insert(run.into(), authority.clone());
Ok(authority)
})
}
pub(crate) fn adopt_legacy_state(
context: &ExecutionContext,
run: &str,
state: &mut RunState,
access: &crate::run_store::RunAccess<'_>,
now: i64,
) -> Result<String, CliError> {
if state.run != run
|| !state.status.is(RunStatus::Planted)
|| state.orientation_epoch != 0
|| !state.lanes.is_empty()
{
return Err(error(
"native adoption requires a pristine planted run with zero orientation epoch and no lanes",
));
}
let run_id = shepherd::dispatch::RunId::new(run).map_err(|source| error(source.to_string()))?;
let inventory = crate::DispatchStore::new(&context.runs_root)
.with_run_access(&run_id, access, |authority| authority.inventory())
.map_err(|source| error(source.to_string()))?;
if !inventory.records.is_empty() || !inventory.pending.is_empty() {
return Err(error(
"native adoption refuses existing child dispatch or pending authority",
));
}
let project_id = if context.project_id_path.exists() {
Some(crate::cmd::dispatch::read_project_id(&context.project_id_path)?.to_string())
} else {
None
};
let mut legacy = state.clone();
legacy.run_incarnation.clear();
let source_sha256 = Sha256::digest(legacy.to_canonical_json().as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let incarnation = with_registry(context, true, |registry| {
if registry.schema != REGISTRY_SCHEMA
|| registry.policy != POLICY
|| registry.project_root != canonical(&context.primary_root)?
{
return Err(error("native adoption registry identity or policy changed"));
}
if let Some(current) = registry.runs.get(run) {
validate_registry_identity(
&context.primary_root,
registry,
current,
run,
project_id.as_deref(),
)?;
if current.legacy_adoption_sha256.as_deref() != Some(source_sha256.as_str())
|| current.orientation_epoch != 0
|| current.pre.is_some()
|| current.post.is_some()
|| !current.pre_history.is_empty()
|| current.project_id != project_id
|| (!state.run_incarnation.is_empty()
&& state.run_incarnation != current.run_incarnation)
{
return Err(error(
"native adoption intent does not match this pristine legacy source",
));
}
return Ok(current.run_incarnation.clone());
}
if !state.run_incarnation.is_empty() {
return Err(error(
"native adoption cannot import a caller-chosen run incarnation",
));
}
let incarnation = new_incarnation();
registry.runs.insert(
run.into(),
RunAuthority {
run: run.into(),
project_id,
run_incarnation: incarnation.clone(),
orientation_epoch: 0,
initialized_at: now,
pre: None,
post: None,
pre_history: Vec::new(),
legacy_adoption_sha256: Some(source_sha256),
},
);
Ok(incarnation)
})?;
state.run_incarnation = incarnation.clone();
Ok(incarnation)
}
pub(crate) fn remove_run(context: &ExecutionContext, run: &str) -> Result<(), CliError> {
let path = registry_path(context);
if !path.exists() {
return Ok(());
}
with_registry(context, true, |registry| {
registry.runs.remove(run);
Ok(())
})
}
pub(crate) fn rename_run(
context: &ExecutionContext,
old: &str,
new: &str,
) -> Result<bool, CliError> {
let path = registry_path(context);
if !path.exists() {
return Ok(false);
}
with_registry(context, true, |registry| {
if registry.runs.contains_key(new) {
return Err(error(format!(
"native registry already contains an incarnation for `{new}`"
)));
}
let Some(mut authority) = registry.runs.remove(old) else {
return Ok(false);
};
authority.run = new.into();
registry.runs.insert(new.into(), authority);
Ok(true)
})
}
pub(crate) fn load_run(
context: &ExecutionContext,
run: &str,
project_id: Option<&str>,
) -> Result<RunAuthority, CliError> {
load_run_at(&context.primary_root, run, project_id)
}
pub(crate) fn load_run_at(
project_root: &Path,
run: &str,
project_id: Option<&str>,
) -> Result<RunAuthority, CliError> {
let registry_path = project_root.join(".shepherd").join(REGISTRY_FILE);
let registry = read_registry_path(®istry_path)?;
let authority = registry.runs.get(run).cloned().ok_or_else(|| {
error(format!(
"native registry has no incarnation for run `{run}`"
))
})?;
validate_registry_identity(project_root, ®istry, &authority, run, project_id)?;
Ok(authority)
}
pub(crate) fn validate_state(
context: &ExecutionContext,
run: &str,
state: &RunState,
project_id: Option<&str>,
) -> Result<RunAuthority, CliError> {
let authority = load_run(context, run, project_id)?;
if state.run != run || state.run_incarnation != authority.run_incarnation {
return Err(error("run state does not match the native run incarnation"));
}
if state.orientation_epoch != authority.orientation_epoch {
return Err(error(
"run state orientation epoch does not match native registry",
));
}
Ok(authority)
}
pub(crate) fn validate_and_bind_state(
context: &ExecutionContext,
run: &str,
state: &RunState,
project_id: &str,
) -> Result<RunAuthority, CliError> {
let authority = validate_state(context, run, state, None)?;
if authority.project_id.is_none() {
bind_project(context, run, project_id)
} else {
validate_state(context, run, state, Some(project_id))
}
}
pub(crate) fn validate_and_bind_pre_state(
context: &ExecutionContext,
run: &str,
state: &RunState,
project_id: &str,
) -> Result<RunAuthority, CliError> {
let mut authority = load_run(context, run, None)?;
if state.run != run || state.run_incarnation != authority.run_incarnation {
return Err(error("run state does not match the native run incarnation"));
}
if authority.project_id.is_none() {
if state.orientation_epoch != authority.orientation_epoch {
return Err(error(
"unbound run state orientation epoch does not match native registry",
));
}
authority = bind_project(context, run, project_id)?;
} else if authority.project_id.as_deref() != Some(project_id) {
return Err(error(
"native run authority belongs to another project identity",
));
}
let recoverable_pre = authority.pre.as_ref().is_some_and(|checkpoint| {
state.orientation_epoch.checked_add(1) == Some(authority.orientation_epoch)
&& checkpoint.epoch == authority.orientation_epoch
&& checkpoint.run_revision == authority.orientation_epoch
});
if state.orientation_epoch != authority.orientation_epoch && !recoverable_pre {
return Err(error(
"run state orientation epoch does not match native registry",
));
}
Ok(authority)
}
pub(crate) fn bind_project(
context: &ExecutionContext,
run: &str,
project_id: &str,
) -> Result<RunAuthority, CliError> {
with_registry(context, true, |registry| {
let authority = registry.runs.get_mut(run).ok_or_else(|| {
error(format!(
"native registry has no incarnation for run `{run}`"
))
})?;
if authority.project_id.is_some() && authority.project_id.as_deref() != Some(project_id) {
return Err(error(
"native run authority belongs to another project identity",
));
}
authority.project_id = Some(project_id.into());
Ok(authority.clone())
})
}
pub(crate) fn record_pre(
context: &ExecutionContext,
run: &str,
authority: &RunAuthority,
checkpoint: PreCheckpoint,
) -> Result<RunAuthority, CliError> {
with_registry(context, true, |registry| {
let current = registry.runs.get_mut(run).ok_or_else(|| {
error(format!(
"native registry has no incarnation for run `{run}`"
))
})?;
if current != authority || current.pre.is_some() {
return Err(error("native pre checkpoint authority changed"));
}
if checkpoint.epoch != current.orientation_epoch + 1 {
return Err(error("native orientation epoch is not monotonic"));
}
current.orientation_epoch = checkpoint.epoch;
current.pre = Some(checkpoint);
current.post = None;
Ok(current.clone())
})
}
pub(crate) fn record_post(
context: &ExecutionContext,
run: &str,
authority: &RunAuthority,
checkpoint: PostCheckpoint,
) -> Result<RunAuthority, CliError> {
with_registry(context, true, |registry| {
let current = registry.runs.get_mut(run).ok_or_else(|| {
error(format!(
"native registry has no incarnation for run `{run}`"
))
})?;
if current != authority || current.pre.is_none() {
return Err(error("native post checkpoint has no matching native pre"));
}
if checkpoint.epoch != current.orientation_epoch {
return Err(error("native post checkpoint epoch is stale"));
}
current.post = Some(checkpoint);
Ok(current.clone())
})
}
pub(crate) fn retire_pre(
context: &ExecutionContext,
run: &str,
authority: &RunAuthority,
retired: RetiredPreCheckpoint,
) -> Result<RunAuthority, CliError> {
with_registry(context, true, |registry| {
let current = registry
.runs
.get_mut(run)
.ok_or_else(|| error("native run is absent"))?;
if current != authority
|| current.pre.as_ref() != Some(&retired.pre)
|| current.post != retired.post
|| retired.pre.epoch != current.orientation_epoch
|| retired.pre.input_archive.is_none()
|| retired.replaced_agent_id.is_empty()
|| retired.replacement_agent_id.is_empty()
|| retired.replaced_agent_id == retired.replacement_agent_id
|| retired.replacement_nonce.is_empty()
|| retired.replacement_nonce == retired.pre.engineer_nonce
{
return Err(error(
"native pre retirement does not match exact replacement custody",
));
}
current.pre_history.push(retired);
current.pre = None;
current.post = None;
Ok(current.clone())
})
}
fn validate_registry_identity(
project_root: &Path,
registry: &Registry,
authority: &RunAuthority,
run: &str,
project_id: Option<&str>,
) -> Result<(), CliError> {
if registry.schema != REGISTRY_SCHEMA || registry.policy != POLICY || authority.run != run {
return Err(error(
"native orientation registry has the wrong schema or policy",
));
}
if registry.project_root != canonical(project_root)? {
return Err(error("native orientation registry project root changed"));
}
if let (Some(expected), Some(bound)) = (project_id, authority.project_id.as_deref())
&& bound != expected
{
return Err(error("native run authority project identity changed"));
}
if authority.run_incarnation.is_empty() {
return Err(error("native run authority has no incarnation"));
}
Ok(())
}
fn read_registry(context: &ExecutionContext) -> Result<Registry, CliError> {
read_registry_path(®istry_path(context))
}
fn read_registry_path(path: &Path) -> Result<Registry, CliError> {
let bytes = read_nofollow(path)?;
serde_json::from_slice(&bytes)
.map_err(|source| error(format!("native orientation registry is invalid: {source}")))
}
fn with_registry<T>(
context: &ExecutionContext,
create: bool,
mutate: impl FnOnce(&mut Registry) -> Result<T, CliError>,
) -> Result<T, CliError> {
let path = registry_path(context);
let parent = path
.parent()
.ok_or_else(|| error("native orientation registry has no parent"))?;
if create {
fs::create_dir_all(parent)
.map_err(|source| error(format!("create registry parent: {source}")))?;
}
reject_path(parent)?;
let lock_path = registry_lock_path(context);
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.map_err(|source| error(format!("open native registry lock: {source}")))?;
acquire_lock(&lock, &lock_path)?;
let mut registry = if path.exists() {
read_registry(context)?
} else {
Registry {
schema: REGISTRY_SCHEMA.into(),
policy: POLICY.into(),
project_root: canonical(&context.primary_root)?,
runs: BTreeMap::new(),
}
};
let result = mutate(&mut registry)?;
write_atomic(&path, ®istry)?;
Ok(result)
}
fn acquire_lock(file: &File, path: &Path) -> Result<(), CliError> {
let started = std::time::Instant::now();
loop {
match file.try_lock() {
Ok(()) => return Ok(()),
Err(std::fs::TryLockError::WouldBlock) if started.elapsed() < LOCK_TIMEOUT => {
std::thread::sleep(Duration::from_millis(10));
}
Err(std::fs::TryLockError::WouldBlock) => {
return Err(error(format!(
"timed out waiting for native registry lock {}",
path.display()
)));
}
Err(std::fs::TryLockError::Error(source)) => {
return Err(error(format!("acquire native registry lock: {source}")));
}
}
}
}
fn write_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), CliError> {
reject_path(path)?;
let parent = path
.parent()
.ok_or_else(|| error("registry has no parent"))?;
let mut bytes = serde_json::to_vec_pretty(value).map_err(|source| error(source.to_string()))?;
bytes.push(b'\n');
if u64::try_from(bytes.len()).map_or(true, |length| length > MAX_BYTES) {
return Err(error(
"native orientation registry exceeds its bounded reader",
));
}
let name = path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| error("registry filename is not UTF-8"))?;
let temporary = parent.join(format!(".{name}.tmp-{}", std::process::id()));
if temporary.exists() {
return Err(error("native registry temporary path already exists"));
}
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
.map_err(|source| error(format!("create native registry temporary: {source}")))?;
file.write_all(&bytes)
.and_then(|_| file.sync_all())
.map_err(|source| error(format!("write native registry: {source}")))?;
fs::rename(&temporary, path).map_err(|source| {
let _ = fs::remove_file(&temporary);
error(format!("publish native registry: {source}"))
})?;
#[cfg(unix)]
fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|source| error(format!("sync native registry parent: {source}")))?;
Ok(())
}
fn read_nofollow(path: &Path) -> Result<Vec<u8>, CliError> {
reject_path(path)?;
let mut file = OpenOptions::new()
.read(true)
.open(path)
.map_err(|source| error(format!("open native orientation registry: {source}")))?;
let before = file
.metadata()
.map_err(|source| error(format!("inspect native orientation registry: {source}")))?;
if !before.is_file() || before.len() > MAX_BYTES {
return Err(error(
"native orientation registry is not a bounded regular file",
));
}
let mut bytes = Vec::new();
(&mut file)
.take(MAX_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|source| error(format!("read native orientation registry: {source}")))?;
if bytes.len() as u64 > MAX_BYTES {
return Err(error("native orientation registry exceeds its byte limit"));
}
let after = file
.metadata()
.map_err(|source| error(format!("inspect native orientation registry: {source}")))?;
if before.len() != after.len() || before.modified().ok() != after.modified().ok() {
return Err(error(
"native orientation registry changed while it was read",
));
}
Ok(bytes)
}
fn canonical(path: &Path) -> Result<String, CliError> {
reject_path(path)?;
fs::canonicalize(path)
.map(|value| value.display().to_string())
.map_err(|source| error(format!("canonicalize {}: {source}", path.display())))
}
fn reject_path(path: &Path) -> Result<(), CliError> {
use std::path::Component;
let mut cursor = PathBuf::new();
for component in path.components() {
cursor.push(component.as_os_str());
if matches!(component, Component::Prefix(_) | Component::RootDir) {
continue;
}
match fs::symlink_metadata(&cursor) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(error(format!(
"native authority path is a symlink: {}",
cursor.display()
)));
}
Ok(_) => {}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => break,
Err(source) => return Err(error(format!("inspect {}: {source}", cursor.display()))),
}
}
Ok(())
}
fn error(message: impl Into<String>) -> CliError {
CliError::message(message.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interrupted_adoption_reuses_the_committed_intent_without_granting_early_authority() {
let root = fs::canonicalize(std::env::temp_dir())
.unwrap()
.join(format!("shepherd-adoption-crash-{}", Uuid::now_v7()));
fs::create_dir(&root).unwrap();
assert!(
std::process::Command::new("git")
.args(["init", "--quiet"])
.current_dir(&root)
.status()
.unwrap()
.success()
);
let run_dir = root.join(".shepherd/runs/v657");
fs::create_dir_all(run_dir.join("dispatch")).unwrap();
let context = ExecutionContext::discover(crate::ContextInputs {
start_dir: root.clone(),
shepherd_home: Some(root.join("isolated-home")),
home_dir: Some(root.join("isolated-home")),
..Default::default()
})
.unwrap();
let state: RunState = serde_json::from_value(serde_json::json!({
"run":"v657", "status":"planted", "updated_at":42,
}))
.unwrap();
let store = crate::RunStore::new(run_dir.join("run.json"));
store.initialize(&state).unwrap();
let before = fs::read(store.path()).unwrap();
let interrupted: crate::run_store::RunStoreResult<()> =
store.update_with_access(|state, access| {
adopt_legacy_state(&context, "v657", state, access, 100).unwrap();
Err(crate::RunStoreError::mutation(
"injected interruption before RunState publication",
))
});
assert!(interrupted.is_err());
assert_eq!(before, fs::read(store.path()).unwrap());
let intent = load_run(&context, "v657", None).unwrap();
assert!(validate_state(&context, "v657", &store.load().unwrap(), None).is_err());
let recovered = store
.update_with_access(|state, access| {
Ok(adopt_legacy_state(&context, "v657", state, access, 200).unwrap())
})
.unwrap();
assert_eq!(recovered, intent.run_incarnation);
assert_eq!(load_run(&context, "v657", None).unwrap(), intent);
assert!(validate_state(&context, "v657", &store.load().unwrap(), None).is_ok());
assert_eq!(store.load().unwrap().updated_at, 42);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn native_tokens_are_nonempty_and_distinct() {
let incarnation = new_incarnation();
let nonce = new_nonce();
assert!(!incarnation.is_empty());
assert!(!nonce.is_empty());
assert_ne!(incarnation, nonce);
}
}