Add strong Rust types for manifest.json and final.json

Replace ad-hoc serde_json::Value construction/parsing with typed Manifest
and RunFinal structs, matching the pattern used by Checkpoint and Retro.
This gives compile-time guarantees for field access and eliminates
stringly-typed indexing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-06 10:46:05 -05:00
parent d6ca768ddc
commit deda9bd512
8 changed files with 312 additions and 95 deletions

View file

@ -712,18 +712,14 @@ pub async fn run_command(
Ok(o) => (o.status.to_string(), o.failure_reason().map(String::from)),
Err(e) => ("fail".to_string(), Some(e.to_string())),
};
let mut final_json = serde_json::json!({
"timestamp": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
"status": status,
"duration_ms": run_duration_ms,
"failure_reason": failure_reason,
});
if let Some(sha) = last_git_sha.lock().unwrap().clone() {
final_json["final_git_commit_sha"] = serde_json::Value::String(sha);
}
if let Ok(json) = serde_json::to_string_pretty(&final_json) {
let _ = tokio::fs::write(logs_dir.join("final.json"), json).await;
}
let run_final = crate::run_final::RunFinal {
timestamp: Utc::now(),
status,
duration_ms: run_duration_ms,
failure_reason,
final_git_commit_sha: last_git_sha.lock().unwrap().clone(),
};
let _ = run_final.save(&logs_dir.join("final.json"));
}
// Finish progress bars before printing summary
@ -991,7 +987,7 @@ async fn run_from_branch(
std::env::set_current_dir(&worktree_path)?;
let base_sha = crate::git::MetadataStore::read_manifest(&original_cwd, &run_id)?
.and_then(|m| m.get("base_sha").and_then(|v| v.as_str()).map(String::from));
.and_then(|m| m.base_sha);
// Build minimal sandbox (local only for now)
let emitter = Arc::new(EventEmitter::new());

View file

@ -88,20 +88,13 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
let manifest_path = path.join("manifest.json");
if manifest_path.exists() {
let manifest_text = std::fs::read_to_string(&manifest_path)?;
debug!(dir = %dir_name, "reading manifest");
let manifest: serde_json::Value = serde_json::from_str(&manifest_text)?;
let manifest = crate::manifest::Manifest::load(&manifest_path)?;
let run_id = manifest["run_id"].as_str().unwrap_or(&dir_name).to_string();
let workflow_name = manifest["workflow_name"]
.as_str()
.unwrap_or("unknown")
.to_string();
let start_time = manifest["start_time"].as_str().unwrap_or("").to_string();
let labels: HashMap<String, String> = manifest
.get("labels")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let run_id = manifest.run_id;
let workflow_name = manifest.workflow_name;
let start_time = manifest.start_time.to_rfc3339();
let labels = manifest.labels;
let status = read_status(&path);
@ -148,12 +141,8 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
fn read_status(run_dir: &Path) -> String {
let final_path = run_dir.join("final.json");
if final_path.exists() {
if let Ok(text) = std::fs::read_to_string(&final_path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(status) = val["status"].as_str() {
return status.to_string();
}
}
if let Ok(run_final) = crate::run_final::RunFinal::load(&final_path) {
return run_final.status;
}
"unknown".to_string()
} else if run_dir.join("run.pid").exists() {
@ -354,10 +343,13 @@ mod tests {
Some(serde_json::json!({
"run_id": "abc123",
"workflow_name": "my-pipeline",
"goal": "test goal",
"start_time": "2026-01-01T12:00:00Z",
"node_count": 2,
"edge_count": 1,
"labels": { "env": "prod" }
})),
Some(serde_json::json!({ "status": "success" })),
Some(serde_json::json!({ "timestamp": "2026-01-01T12:01:00Z", "status": "success", "duration_ms": 60000 })),
false,
);
@ -388,7 +380,10 @@ mod tests {
Some(serde_json::json!({
"run_id": "running-1",
"workflow_name": "pipeline-a",
"start_time": "2026-01-15T10:00:00Z"
"goal": "",
"start_time": "2026-01-15T10:00:00Z",
"node_count": 1,
"edge_count": 0
})),
None,
true,
@ -535,9 +530,12 @@ mod tests {
Some(serde_json::json!({
"run_id": "to-prune",
"workflow_name": "old-pipeline",
"start_time": "2025-01-01T12:00:00Z"
"goal": "",
"start_time": "2025-01-01T12:00:00Z",
"node_count": 1,
"edge_count": 0
})),
Some(serde_json::json!({ "status": "success" })),
Some(serde_json::json!({ "timestamp": "2025-01-01T12:01:00Z", "status": "success", "duration_ms": 60000 })),
false,
);
@ -564,9 +562,12 @@ mod tests {
Some(serde_json::json!({
"run_id": "to-prune",
"workflow_name": "old-pipeline",
"start_time": "2025-01-01T12:00:00Z"
"goal": "",
"start_time": "2025-01-01T12:00:00Z",
"node_count": 1,
"edge_count": 0
})),
Some(serde_json::json!({ "status": "success" })),
Some(serde_json::json!({ "timestamp": "2025-01-01T12:01:00Z", "status": "success", "duration_ms": 60000 })),
false,
);
@ -577,9 +578,12 @@ mod tests {
Some(serde_json::json!({
"run_id": "keep-this",
"workflow_name": "new-pipeline",
"start_time": "2026-03-01T12:00:00Z"
"goal": "",
"start_time": "2026-03-01T12:00:00Z",
"node_count": 1,
"edge_count": 0
})),
Some(serde_json::json!({ "status": "success" })),
Some(serde_json::json!({ "timestamp": "2026-03-01T12:01:00Z", "status": "success", "duration_ms": 60000 })),
false,
);

View file

@ -272,34 +272,26 @@ pub fn resolve_thread_id(
// --- Run directory helpers (spec 5.6) ---
/// Write manifest.json at the start of a workflow run. Returns the manifest value.
fn write_manifest(logs_root: &Path, graph: &Graph, config: &RunConfig) -> serde_json::Value {
/// Write manifest.json at the start of a workflow run. Returns the manifest.
fn write_manifest(logs_root: &Path, graph: &Graph, config: &RunConfig) -> crate::manifest::Manifest {
let workflow_name = if graph.name.is_empty() {
"unnamed"
"unnamed".to_string()
} else {
&graph.name
graph.name.clone()
};
let mut manifest = serde_json::json!({
"run_id": config.run_id,
"workflow_name": workflow_name,
"goal": graph.goal(),
"start_time": Utc::now().to_rfc3339(),
"node_count": graph.nodes.len(),
"edge_count": graph.edges.len(),
});
if let Some(ref branch) = config.run_branch {
manifest["run_branch"] = serde_json::Value::String(branch.clone());
}
if let Some(ref base) = config.base_sha {
manifest["base_sha"] = serde_json::Value::String(base.clone());
}
if !config.labels.is_empty() {
manifest["labels"] = serde_json::to_value(&config.labels).unwrap_or_default();
}
if let Ok(json) = serde_json::to_string_pretty(&manifest) {
let _ = std::fs::create_dir_all(logs_root);
let _ = std::fs::write(logs_root.join("manifest.json"), json);
}
let manifest = crate::manifest::Manifest {
run_id: config.run_id.clone(),
workflow_name,
goal: graph.goal().to_string(),
start_time: Utc::now(),
node_count: graph.nodes.len(),
edge_count: graph.edges.len(),
run_branch: config.run_branch.clone(),
base_sha: config.base_sha.clone(),
labels: config.labels.clone(),
};
let _ = std::fs::create_dir_all(logs_root);
let _ = manifest.save(&logs_root.join("manifest.json"));
manifest
}
@ -2939,13 +2931,11 @@ mod tests {
let manifest_path = dir.path().join("manifest.json");
assert!(manifest_path.exists());
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
assert_eq!(manifest["workflow_name"], "test_pipeline");
assert_eq!(manifest["goal"], "Run tests");
assert!(manifest["start_time"].is_string());
assert!(manifest["node_count"].is_number());
assert!(manifest["edge_count"].is_number());
let manifest = crate::manifest::Manifest::load(&manifest_path).unwrap();
assert_eq!(manifest.workflow_name, "test_pipeline");
assert_eq!(manifest.goal, "Run tests");
assert!(manifest.node_count > 0);
assert!(manifest.edge_count > 0);
}
#[tokio::test]
@ -2967,11 +2957,9 @@ mod tests {
};
engine.run(&g, &config).await.unwrap();
let manifest: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(dir.path().join("manifest.json")).unwrap(),
)
.unwrap();
assert_eq!(manifest["labels"]["env"], "test");
let manifest =
crate::manifest::Manifest::load(&dir.path().join("manifest.json")).unwrap();
assert_eq!(manifest.labels.get("env").map(String::as_str), Some("test"));
}
#[tokio::test]
@ -2993,11 +2981,9 @@ mod tests {
};
engine.run(&g, &config).await.unwrap();
let manifest: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(dir.path().join("manifest.json")).unwrap(),
)
.unwrap();
assert!(manifest.get("labels").is_none());
let manifest =
crate::manifest::Manifest::load(&dir.path().join("manifest.json")).unwrap();
assert!(manifest.labels.is_empty());
}
#[tokio::test]
@ -3202,9 +3188,8 @@ mod tests {
engine.run(&g, &config).await.unwrap();
let manifest_path = dir.path().join("manifest.json");
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
assert_eq!(manifest["goal"], "Run tests");
let manifest = crate::manifest::Manifest::load(&manifest_path).unwrap();
assert_eq!(manifest.goal, "Run tests");
}
#[tokio::test]
@ -3241,9 +3226,8 @@ mod tests {
engine.run(&g, &config).await.unwrap();
let manifest_path = dir.path().join("manifest.json");
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
assert_eq!(manifest["goal"], "");
let manifest = crate::manifest::Manifest::load(&manifest_path).unwrap();
assert_eq!(manifest.goal, "");
}
// --- Gap #1: Auto status tests ---

View file

@ -346,13 +346,13 @@ impl MetadataStore {
}
}
/// Read the manifest JSON from the metadata branch. Returns `None` if not found.
pub fn read_manifest(repo_path: &Path, run_id: &str) -> Result<Option<serde_json::Value>> {
/// Read the manifest from the metadata branch. Returns `None` if not found.
pub fn read_manifest(repo_path: &Path, run_id: &str) -> Result<Option<crate::manifest::Manifest>> {
match Self::read_file(repo_path, run_id, "manifest.json")? {
Some(bytes) => {
let val: serde_json::Value = serde_json::from_slice(&bytes)
let manifest: crate::manifest::Manifest = serde_json::from_slice(&bytes)
.map_err(|e| git_error(format!("manifest deserialize failed: {e}")))?;
Ok(Some(val))
Ok(Some(manifest))
}
None => Ok(None),
}
@ -707,15 +707,15 @@ mod tests {
init_repo(dir.path());
let store = MetadataStore::new(dir.path());
let manifest = br#"{"run_id":"RUN1","pipeline":"test"}"#;
let manifest = br#"{"run_id":"RUN1","workflow_name":"test","goal":"g","start_time":"2025-01-01T00:00:00Z","node_count":2,"edge_count":1}"#;
let dot = b"digraph { start -> end }";
store.init_run("RUN1", manifest, dot).unwrap();
let read_manifest = MetadataStore::read_manifest(dir.path(), "RUN1")
.unwrap()
.unwrap();
assert_eq!(read_manifest["run_id"], "RUN1");
assert_eq!(read_manifest["pipeline"], "test");
assert_eq!(read_manifest.run_id, "RUN1");
assert_eq!(read_manifest.workflow_name, "test");
let read_dot = MetadataStore::read_graph_dot(dir.path(), "RUN1")
.unwrap()

View file

@ -14,11 +14,13 @@ pub mod graph;
pub mod handler;
pub mod hook;
pub mod interviewer;
pub mod manifest;
pub mod outcome;
pub mod parser;
pub mod preamble;
pub mod retro;
pub mod retro_agent;
pub mod run_final;
pub mod stylesheet;
pub mod transform;
pub mod validation;

View file

@ -0,0 +1,111 @@
use std::collections::HashMap;
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{ArcError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub run_id: String,
pub workflow_name: String,
pub goal: String,
pub start_time: DateTime<Utc>,
pub node_count: usize,
pub edge_count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_sha: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
}
impl Manifest {
pub fn save(&self, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| ArcError::Checkpoint(format!("manifest serialize failed: {e}")))?;
std::fs::write(path, json)?;
Ok(())
}
pub fn load(path: &Path) -> Result<Self> {
let data = std::fs::read_to_string(path)?;
let manifest: Self = serde_json::from_str(&data)
.map_err(|e| ArcError::Checkpoint(format!("manifest deserialize failed: {e}")))?;
Ok(manifest)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_manifest() -> Manifest {
Manifest {
run_id: "run-1".to_string(),
workflow_name: "test_pipeline".to_string(),
goal: "Fix the bug".to_string(),
start_time: Utc::now(),
node_count: 3,
edge_count: 2,
run_branch: Some("feature/test".to_string()),
base_sha: Some("abc123".to_string()),
labels: HashMap::from([("env".into(), "test".into())]),
}
}
#[test]
fn save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
let manifest = sample_manifest();
manifest.save(&path).unwrap();
let loaded = Manifest::load(&path).unwrap();
assert_eq!(loaded.run_id, "run-1");
assert_eq!(loaded.workflow_name, "test_pipeline");
assert_eq!(loaded.goal, "Fix the bug");
assert_eq!(loaded.node_count, 3);
assert_eq!(loaded.edge_count, 2);
assert_eq!(loaded.run_branch.as_deref(), Some("feature/test"));
assert_eq!(loaded.base_sha.as_deref(), Some("abc123"));
assert_eq!(loaded.labels.get("env").map(String::as_str), Some("test"));
}
#[test]
fn load_nonexistent_file() {
let result = Manifest::load(Path::new("/nonexistent/manifest.json"));
assert!(result.is_err());
}
#[test]
fn load_invalid_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "not json").unwrap();
let result = Manifest::load(&path);
assert!(result.is_err());
}
#[test]
fn labels_omitted_when_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
let mut manifest = sample_manifest();
manifest.labels = HashMap::new();
manifest.run_branch = None;
manifest.base_sha = None;
manifest.save(&path).unwrap();
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(raw.get("labels").is_none());
assert!(raw.get("run_branch").is_none());
assert!(raw.get("base_sha").is_none());
}
}

View file

@ -0,0 +1,120 @@
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{ArcError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunFinal {
pub timestamp: DateTime<Utc>,
pub status: String,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_git_commit_sha: Option<String>,
}
impl RunFinal {
pub fn save(&self, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| ArcError::Checkpoint(format!("run_final serialize failed: {e}")))?;
std::fs::write(path, json)?;
Ok(())
}
pub fn load(path: &Path) -> Result<Self> {
let data = std::fs::read_to_string(path)?;
let run_final: Self = serde_json::from_str(&data)
.map_err(|e| ArcError::Checkpoint(format!("run_final deserialize failed: {e}")))?;
Ok(run_final)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_run_final() -> RunFinal {
RunFinal {
timestamp: Utc::now(),
status: "success".to_string(),
duration_ms: 12345,
failure_reason: None,
final_git_commit_sha: Some("deadbeef".to_string()),
}
}
#[test]
fn save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("final.json");
let run_final = sample_run_final();
run_final.save(&path).unwrap();
let loaded = RunFinal::load(&path).unwrap();
assert_eq!(loaded.status, "success");
assert_eq!(loaded.duration_ms, 12345);
assert!(loaded.failure_reason.is_none());
assert_eq!(
loaded.final_git_commit_sha.as_deref(),
Some("deadbeef")
);
}
#[test]
fn load_nonexistent_file() {
let result = RunFinal::load(Path::new("/nonexistent/final.json"));
assert!(result.is_err());
}
#[test]
fn load_invalid_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "not json").unwrap();
let result = RunFinal::load(&path);
assert!(result.is_err());
}
#[test]
fn optional_fields_omitted_when_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("final.json");
let run_final = RunFinal {
timestamp: Utc::now(),
status: "fail".to_string(),
duration_ms: 500,
failure_reason: None,
final_git_commit_sha: None,
};
run_final.save(&path).unwrap();
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(raw.get("failure_reason").is_none());
assert!(raw.get("final_git_commit_sha").is_none());
}
#[test]
fn failure_reason_present() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("final.json");
let run_final = RunFinal {
timestamp: Utc::now(),
status: "fail".to_string(),
duration_ms: 100,
failure_reason: Some("timeout".to_string()),
final_git_commit_sha: None,
};
run_final.save(&path).unwrap();
let loaded = RunFinal::load(&path).unwrap();
assert_eq!(loaded.failure_reason.as_deref(), Some("timeout"));
}
}

View file

@ -10019,7 +10019,7 @@ async fn git_checkpoint_host_writes_shadow_branch() {
let manifest = MetadataStore::read_manifest(repo.path(), run_id)
.expect("read_manifest should not error")
.expect("shadow branch should contain manifest");
assert_eq!(manifest["run_id"], run_id);
assert_eq!(manifest.run_id, run_id);
// Cleanup worktree
let _ = std::process::Command::new("git")