use crate::error::ApiError;
use crate::read_work::ReadWork;
use crate::ServerState;
use axum::body::Bytes;
use axum::extract::{Path as UrlPath, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::{Extension, Json};
use kranz_engine::contract_lint::ContractLintReport;
use kranz_engine::cost;
use kranz_engine::event_log::EventLog;
use kranz_engine::events::{Event, EventKind};
use kranz_engine::merged::merged_bit;
use kranz_engine::orchestrator::mission_worktree_path;
use kranz_engine::paths::MissionPaths;
use kranz_engine::preflight::PREFLIGHT_CLEAR_SUMMARY;
use kranz_engine::reducer;
use kranz_engine::report_render::render_plan_markdown;
use kranz_engine::types::{
ControlCommand, MissionState, MissionStatus, RoleConfig, SandboxEnforce, WorkerIsolation,
};
use kranz_engine::{config, control};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::io::ErrorKind;
use std::path::Path;
use std::sync::Arc;
pub(crate) async fn health() -> Json<Value> {
Json(json!({ "ok": true, "version": env!("CARGO_PKG_VERSION") }))
}
pub(crate) async fn list_missions(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
) -> Result<Json<Value>, ApiError> {
reads
.run(move || {
let index_contents = read_missions_index_sync(&server.repo_root);
let mut ids = MissionPaths::list_missions(&server.repo_root);
for id in kranz_engine::mission_catalog::mission_index_ids(&index_contents) {
if !ids.contains(&id) {
ids.push(id);
}
}
ids.sort();
let repo = kranz_engine::git_ops::GitRepo::open(&server.repo_root).ok();
let mut rows = Vec::new();
for id in ids {
let paths = MissionPaths::new(&server.repo_root, &id);
if !paths.events_file().is_file() {
rows.push(json!({
"id": id,
"status": "deleted",
"goal": "deleted mission (no data recorded)",
}));
continue;
}
if let Err(error) = paths.require_no_follow() {
rows.push(json!({ "id": id, "status": "failed", "error": error.to_string() }));
continue;
}
let row = match fold_log(&paths) {
Ok(state) => {
let merged = repo
.as_ref()
.and_then(|repo| merged_bit(repo, &state.mission));
json!({
"id": id,
"status": state.mission.status,
"goal": state.mission.goal,
"createdAt": state.mission.created_at,
"merged": merged,
})
}
Err(error) => json!({ "id": id, "status": "failed", "error": error }),
};
rows.push(row);
}
Ok(Json(Value::Array(rows)))
})
.await
}
pub(crate) async fn mission_outcomes(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
) -> Result<Json<kranz_engine::outcomes::Outcomes>, ApiError> {
reads
.run(move || {
let outcomes = kranz_engine::outcomes::compute_outcomes(&server.repo_root)
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(Json(outcomes))
})
.await
}
pub(crate) async fn escalation_metrics(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
) -> Result<Json<kranz_engine::escalation_metrics::EscalationMetrics>, ApiError> {
reads
.run(move || {
let metrics =
kranz_engine::escalation_metrics::compute_escalation_metrics(&server.repo_root)
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(Json(metrics))
})
.await
}
pub(crate) async fn standards_metrics(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
) -> Result<Json<kranz_engine::standards_metrics::StandardsMetricsReport>, ApiError> {
reads
.run(move || {
Ok(Json(kranz_engine::standards_metrics::compute(
&server.repo_root,
)?))
})
.await
}
pub(crate) async fn cost_per_merged_change(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<kranz_engine::outcomes::CostPerMergedChange>, ApiError> {
reads
.run(move || {
let window_days = match params.get("windowDays") {
Some(raw) => {
let parsed = raw.parse::<u64>().map_err(|_| {
ApiError::bad_request("windowDays must be a non-negative integer")
})?;
if parsed > kranz_engine::outcomes::MAX_MERGED_CHANGE_WINDOW_DAYS {
return Err(ApiError::bad_request(format!(
"windowDays must be at most {} days",
kranz_engine::outcomes::MAX_MERGED_CHANGE_WINDOW_DAYS
)));
}
parsed
}
None => kranz_engine::outcomes::DEFAULT_MERGED_CHANGE_WINDOW_DAYS,
};
let report = kranz_engine::outcomes::compute_cost_per_merged_change(
&server.repo_root,
window_days,
chrono::Utc::now(),
)
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(Json(report))
})
.await
}
fn read_missions_index_sync(repo_root: &Path) -> String {
let path = MissionPaths::new(repo_root, "_")
.missions_dir()
.join("index.md");
read_file_or_404_sync(&path, "no mission index".into()).unwrap_or_default()
}
pub(crate) async fn mission_state(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
) -> Result<Json<MissionState>, ApiError> {
reads
.run(move || {
let paths = mission_paths(&server, &id)?;
let events_path = paths.events_file();
if !events_path.is_file() {
return Err(unknown_mission(&id));
}
let events = EventLog::read_events(&events_path)?;
Ok(Json(reducer::fold(&events)?))
})
.await
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct StandardsWaiverCandidate {
rule: kranz_engine::types::PinnedRule,
finding_subject: String,
finding_evidence: String,
run_id: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MissionStandardsView {
#[serde(skip_serializing_if = "Option::is_none")]
manifest: Option<kranz_engine::types::StandardsPin>,
#[serde(skip_serializing_if = "Option::is_none")]
coverage: Option<kranz_engine::standards_coverage::StandardsCoverage>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
waiver_candidates: Vec<StandardsWaiverCandidate>,
}
fn fold_standards_view(
id: &str,
events: &[Event],
) -> kranz_engine::error::Result<MissionStandardsView> {
let state = reducer::fold(events)?;
let manifest = state.mission.standards_manifest.clone();
let coverage = kranz_engine::standards_coverage::standards_coverage(id, events);
let mut waiver_candidates = Vec::new();
if let (Some(pin), Some(coverage)) = (manifest.as_ref(), coverage.as_ref()) {
for row in coverage.rules.iter().filter(|row| {
row.disposition == kranz_engine::standards_coverage::RuleDisposition::Failed
}) {
let Some(rule) = pin
.rules
.iter()
.find(|rule| rule.id == row.id && rule.revision == row.revision && rule.waivable)
else {
continue;
};
if let Some((finding_subject, finding_evidence, run_id)) = events
.iter()
.filter(|event| event.mission_id == id)
.rev()
.find_map(|event| match &event.kind {
EventKind::ValidationFinding {
finding, run_id, ..
} if finding.rule.as_ref().is_some_and(|citation| {
citation.id == rule.id
&& citation.revision == rule.revision
&& citation.digest == pin.digest
}) =>
{
Some((
finding.subject.clone(),
finding.evidence.clone(),
run_id.clone(),
))
}
_ => None,
})
{
waiver_candidates.push(StandardsWaiverCandidate {
rule: rule.clone(),
finding_subject,
finding_evidence,
run_id,
});
}
}
}
Ok(MissionStandardsView {
manifest,
coverage,
waiver_candidates,
})
}
pub(crate) async fn mission_standards(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
) -> Result<Json<MissionStandardsView>, ApiError> {
reads
.run(move || {
let paths = mission_paths(&server, &id)?;
if !paths.events_file().is_file() {
return Err(unknown_mission(&id));
}
let events = EventLog::read_events(&paths.events_file())?;
Ok(Json(fold_standards_view(&id, &events)?))
})
.await
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct StandardsWaiverBody {
rule_id: String,
#[serde(default)]
revision: Option<u64>,
#[serde(default)]
finding_subject: Option<String>,
reason: String,
expires_at: String,
}
pub(crate) async fn post_standards_waiver(
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
Json(body): Json<StandardsWaiverBody>,
) -> Result<Json<Value>, ApiError> {
mission_paths(&server, &id)?;
let expires_at = chrono::DateTime::parse_from_rfc3339(&body.expires_at)
.map_err(|error| ApiError::bad_request(format!("expiresAt must be RFC 3339: {error}")))?
.with_timezone(&chrono::Utc);
let request = kranz_engine::standards_waiver::WaiverRequest {
rule_id: body.rule_id,
revision: body.revision,
finding_subject: body.finding_subject,
reason: body.reason,
expires_at,
};
let outcome = kranz_engine::standards_waiver::approve_standards_waiver(
&server.repo_root,
&id,
&request,
"rest",
kranz_engine::event_log::LockForce::No,
)
.map_err(|error| match error {
kranz_engine::error::EngineError::LockHeld(_) => ApiError::conflict(format!(
"waiver refused: mission '{id}' is still running; pause or stop it before approving this exception"
)),
other => ApiError::unprocessable(format!("waiver refused: {other}")),
})?;
Ok(Json(json!({
"recorded": true,
"seq": outcome.event.seq,
"rule": outcome.rule,
"findingSubject": outcome.finding_subject,
"findingEvidence": outcome.finding_evidence,
"runId": outcome.run_id,
"affectedPaths": outcome.affected_paths,
"diffDigest": outcome.diff_digest,
"findingFingerprint": outcome.finding_fingerprint,
})))
}
pub(crate) async fn mission_workspace(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
) -> Result<Json<Value>, ApiError> {
reads.run(move || {
let paths = mission_paths(&server, &id)?;
if !paths.events_file().is_file() {
return Err(unknown_mission(&id));
}
let events = EventLog::read_events(&paths.events_file())?;
let state = reducer::fold(&events)?;
let isolation = state.config.isolation();
let cwd = match isolation {
WorkerIsolation::Worktree => mission_worktree_path(&server.repo_root, &id),
WorkerIsolation::Checkout => server.repo_root.clone(),
};
let worktree_active = isolation == WorkerIsolation::Worktree && cwd.is_dir();
let lifecycle = match isolation {
WorkerIsolation::Checkout => "primary-checkout",
WorkerIsolation::Worktree if worktree_active => "active",
WorkerIsolation::Worktree
if matches!(
state.mission.status,
MissionStatus::Planning | MissionStatus::Approved
) =>
{
"pending"
}
WorkerIsolation::Worktree => "removed",
};
let preflight = events.iter().rev().find_map(|event| match &event.kind {
EventKind::OrchestratorDecision { summary, .. } if summary.starts_with("preflight:") => {
Some(json!({
"status": if summary == PREFLIGHT_CLEAR_SUMMARY { "clear" } else { "issues" },
"summary": summary,
"eventSeq": event.seq,
}))
}
_ => None,
});
let preflight = preflight.unwrap_or_else(|| {
let pending = matches!(
state.mission.status,
MissionStatus::Planning | MissionStatus::Approved
);
json!({
"status": if pending { "pending" } else { "clear" },
"summary": if pending {
"environment preflight has not run yet"
} else {
"no advisory preflight issues recorded"
},
"eventSeq": Value::Null,
})
});
let workspace_contract =
kranz_engine::workspace_contract::load_workspace_contract(&server.repo_root)
.ok()
.flatten();
let gate_outcome = |prefix: &str| {
events
.iter()
.rev()
.find_map(|event| match &event.kind {
EventKind::OrchestratorDecision { summary, .. } if summary.starts_with(prefix) => {
Some(json!({
"summary": summary,
"eventSeq": event.seq,
}))
}
_ => None,
})
.unwrap_or(Value::Null)
};
let (bootstrap, readiness) = if workspace_contract.is_some() {
(
gate_outcome(kranz_engine::workspace_gate::BOOTSTRAP_SUMMARY_PREFIX),
gate_outcome(kranz_engine::workspace_gate::READINESS_SUMMARY_PREFIX),
)
} else {
(Value::Null, Value::Null)
};
let pin = match &state.workspace_pin {
Some(pin) => json!(pin),
None => Value::Null,
};
let readiness_passed = events.iter().any(|event| {
matches!(
&event.kind,
EventKind::WorkspaceReadinessReport { outcome, .. } if outcome == "ready"
)
});
let remote_provision = events.iter().rev().find_map(|event| match &event.kind {
EventKind::WorkspaceProvisioned {
provider,
takeover,
previews,
..
} if provider == "remote" => Some((takeover.clone(), previews.clone())),
_ => None,
});
let remote_pinned = matches!(
&state.workspace_pin,
Some(pin) if pin.provider == "remote"
);
let previews = if remote_pinned {
match (&remote_provision, readiness_passed) {
(Some((_, Some(previews))), true) if !previews.is_empty() => json!(previews),
_ => Value::Null,
}
} else {
match (&workspace_contract, readiness_passed) {
(Some(contract), true) if !contract.previews.is_empty() => {
json!(contract
.previews
.iter()
.map(|p| json!({
"name": p.name,
"urlTemplate": p.url_template,
}))
.collect::<Vec<_>>())
}
_ => Value::Null,
}
};
let takeover = match &state.workspace_pin {
Some(pin) if pin.provider == "local-worktree" => json!(format!(
"work locally in the workspace cwd ({})",
cwd.to_string_lossy()
)),
Some(pin) if pin.provider == "remote" => match &remote_provision {
Some((Some(takeover), _)) => json!(takeover),
_ => Value::Null,
},
_ => Value::Null,
};
let workspace_lifecycle = match &state.workspace_lifecycle {
Some(lifecycle) => json!(lifecycle),
None => Value::Null,
};
Ok(Json(json!({
"isolation": isolation,
"cwd": cwd.to_string_lossy(),
"lifecycle": lifecycle,
"worktreeActive": worktree_active,
"sandboxes": [
sandbox_summary("worker", &state.config.worker),
sandbox_summary("scrutiny", &state.config.validator_scrutiny),
sandbox_summary("functional", &state.config.validator_functional),
],
"preflight": preflight,
"pin": pin,
"previews": previews,
"takeover": takeover,
"workspaceLifecycle": workspace_lifecycle,
"contract": {
"present": workspace_contract.is_some(),
"services": workspace_contract.as_ref().map_or(0, |c| c.services.len()),
"previews": workspace_contract.as_ref().map_or(0, |c| c.previews.len()),
"bootstrap": bootstrap,
"readiness": readiness,
},
})))
})
.await
}
fn sandbox_summary(role: &str, config: &RoleConfig) -> Value {
json!({
"role": role,
"enforce": sandbox_enforce_label(config.sandbox.enforce),
"extraWriteCount": config.sandbox.extra_write.len(),
"egressCount": config.sandbox.egress.len(),
})
}
fn sandbox_enforce_label(enforce: SandboxEnforce) -> &'static str {
match enforce {
SandboxEnforce::Off => "off",
SandboxEnforce::Fs => "fs",
SandboxEnforce::FsNet => "fs+net",
}
}
pub(crate) async fn mission_events(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Vec<Event>>, ApiError> {
reads
.run(move || {
let paths = mission_paths(&server, &id)?;
let events_path = paths.events_file();
if !events_path.is_file() {
return Err(unknown_mission(&id));
}
let since = match params.get("since") {
None => 0,
Some(raw) => raw.parse::<u64>().map_err(|_| {
ApiError::bad_request(format!("invalid 'since' value: '{raw}'"))
})?,
};
Ok(Json(EventLog::read_events_after(&events_path, since)?))
})
.await
}
pub(crate) async fn mission_plan(
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
) -> Result<Json<Value>, ApiError> {
crate::read_work::run(move || {
let paths = mission_paths(&server, &id)?;
let content = read_file_or_404_sync(
&paths.plan_file(),
format!("mission '{id}' has no approved plan yet"),
)?;
let plan: Value = serde_json::from_str(&content)
.map_err(|e| ApiError::internal(format!("plan.json is not valid JSON: {e}")))?;
Ok(Json(plan))
})
.await
}
pub(crate) async fn mission_plan_md(
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
) -> Result<Json<Value>, ApiError> {
crate::read_work::run(move || {
let paths = mission_paths(&server, &id)?;
let markdown = read_file_or_404_sync(
&paths.plan_md_file(),
format!("mission '{id}' has no approved plan yet"),
)?;
Ok(Json(json!({ "markdown": markdown })))
})
.await
}
pub(crate) async fn mission_revision_diff(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
) -> Result<Json<Value>, ApiError> {
reads
.run(move || {
let paths = mission_paths(&server, &id)?;
if !paths.events_file().is_file() {
return Err(unknown_mission(&id));
}
let state = fold_log(&paths).map_err(ApiError::internal)?;
let Some(pending) = state.pending_revision.as_ref() else {
return Err(ApiError::not_found(format!(
"mission '{id}' has no pending revision"
)));
};
let current = read_file_or_404_sync(
&paths.plan_md_file(),
format!("mission '{id}' has no approved plan yet"),
)?;
let calibration = cost::calibrate(&paths.repo_root);
let estimate = cost::apply_shape(
cost::estimate(&pending.plan, &state.config, &calibration.params),
&pending.plan,
&calibration,
);
let no_lint = ContractLintReport {
results: Vec::new(),
tree_clean_at_base: true,
};
let revised = render_plan_markdown(
&pending.plan,
&state.mission,
&estimate,
kranz_engine::cost::estimate_two_path(estimate, &state.config, &calibration.params)
.as_ref(),
None,
calibration.missions_used,
&no_lint,
&[],
&state.config.worker_candidates,
);
Ok(Json(json!({
"revision": pending.revision,
"instructions": pending.instructions,
"markdown": revised,
"diff": simple_line_diff("plan.md", "revised-plan.md", ¤t, &revised),
})))
})
.await
}
pub(crate) async fn mission_report_md(
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
) -> Result<Json<Value>, ApiError> {
crate::read_work::run(move || {
let paths = mission_paths(&server, &id)?;
let markdown = read_file_or_404_sync(
&paths.report_file(),
format!("mission '{id}' has no report yet"),
)?;
Ok(Json(json!({ "markdown": markdown })))
})
.await
}
pub(crate) async fn mission_diff_stat(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
) -> Result<Json<Value>, ApiError> {
reads
.run(move || {
let paths = mission_paths(&server, &id)?;
if !paths.events_file().is_file() {
return Err(unknown_mission(&id));
}
let state = fold_log(&paths).map_err(ApiError::internal)?;
let Some(base_sha) = state.mission.base_sha else {
return Err(ApiError::not_found(format!(
"mission '{id}' has no pinned base yet"
)));
};
let repo = kranz_engine::git_ops::GitRepo::open(&server.repo_root)?;
if !repo.branch_exists(&state.mission.mission_branch)? {
return Err(ApiError::not_found(format!(
"mission '{id}' has no mission branch yet"
)));
}
let tip = repo.rev_parse(&state.mission.mission_branch)?;
let diff_stat = repo.diff_stat(&base_sha, &tip)?;
Ok(Json(json!({
"diffStat": diff_stat,
"baseSha": base_sha,
"tip": tip,
})))
})
.await
}
pub(crate) async fn mission_pr_handoff(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
) -> Result<Json<Value>, ApiError> {
reads
.run(move || {
let paths = mission_paths(&server, &id)?;
if !paths.events_file().is_file() {
return Err(unknown_mission(&id));
}
let handoff = kranz_engine::pr_handoff::assess_mission(&server.repo_root, &id)
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(Json(
serde_json::to_value(handoff).map_err(|e| ApiError::internal(e.to_string()))?,
))
})
.await
}
pub(crate) async fn mission_pr_create(
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
) -> Result<Json<Value>, ApiError> {
let paths = mission_paths(&server, &id)?;
if !paths.events_file().is_file() {
return Err(unknown_mission(&id));
}
let handoff = kranz_engine::pr_handoff::assess_mission(&server.repo_root, &id)
.map_err(|e| ApiError::internal(e.to_string()))?;
let url = kranz_engine::pr_handoff::create_pull_request(&server.repo_root, &handoff)
.map_err(|e| ApiError::conflict(e.to_string()))?;
Ok(Json(json!({ "url": url })))
}
pub(crate) async fn mission_readiness(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
) -> Result<Json<Value>, ApiError> {
reads
.run(move || {
let _ = mission_paths(&server, &id)?;
let report = kranz_engine::backend_readiness::probe_mission(&server.repo_root, &id)
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(Json(
serde_json::to_value(report).map_err(|e| ApiError::internal(e.to_string()))?,
))
})
.await
}
pub(crate) async fn post_hook_status(
State(server): State<Arc<ServerState>>,
Json(body): Json<kranz_engine::hook_status::SignalPost>,
) -> Result<impl IntoResponse, ApiError> {
use kranz_engine::hook_status::RecordRejection;
match kranz_engine::hook_status::record_signal(
&server.repo_root,
&body.mission_id,
&body.run_id,
&body.token,
body.signal,
body.detail.as_deref(),
chrono::Utc::now(),
) {
Ok(_) => Ok((StatusCode::ACCEPTED, Json(json!({ "recorded": true })))),
Err(RecordRejection::UnsafeId) | Err(RecordRejection::UnknownRun) => Err(
ApiError::not_found(format!("unknown hook-status run '{}'", body.run_id)),
),
Err(RecordRejection::TokenMismatch) => Err(ApiError::unauthorized(
"hook-status token does not match this run's registration",
)),
Err(RecordRejection::Stale) => Err(ApiError::unauthorized(
"hook-status registration is stale (past its acceptance TTL)",
)),
Err(RecordRejection::RegistrationUnreadable) => Err(ApiError::internal(
"hook-status projection entry could not be read or written",
)),
}
}
pub(crate) async fn mission_hook_status(
Extension(reads): Extension<ReadWork>,
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
) -> Result<Json<Value>, ApiError> {
reads.run(move || {
let paths = mission_paths(&server, &id)?;
if !paths.events_file().is_file() {
return Err(unknown_mission(&id));
}
let runs = kranz_engine::hook_status::read_mission_signals(&server.repo_root, &id);
Ok(Json(json!({
"missionId": id,
"authoritative": false,
"note": "hook-derived lifecycle signals; observability only, never folded mission state",
"runs": runs,
})))
})
.await
}
pub(crate) async fn run_transcript(
State(server): State<Arc<ServerState>>,
UrlPath((id, run_id)): UrlPath<(String, String)>,
) -> Result<Json<Value>, ApiError> {
crate::read_work::run(move || {
let paths = mission_paths(&server, &id)?;
if !safe_id(&run_id) {
return Err(ApiError::not_found(format!("unknown run '{run_id}'")));
}
let content = read_file_or_404_sync(
&paths.transcript_file(&run_id),
format!("no transcript for run '{run_id}'"),
)?;
let values: Vec<Value> = content
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
Ok(Json(Value::Array(values)))
})
.await
}
pub(crate) async fn post_control(
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
body: Bytes,
) -> Result<impl IntoResponse, ApiError> {
let paths = mission_paths(&server, &id)?;
if !paths.mission_dir().is_dir() {
return Err(unknown_mission(&id));
}
if paths.events_file().is_file() {
if let Some(status) =
terminal_status_from_tail(&paths).map_err(|e| ApiError::internal(e.to_string()))?
{
return Err(ApiError::conflict(format!(
"mission '{id}' is {status:?}; control commands apply only to active missions"
)));
}
}
let command: ControlCommand = serde_json::from_slice(&body)
.map_err(|e| ApiError::bad_request(format!("invalid ControlCommand body: {e}")))?;
if let ControlCommand::ConfigChange { patch } = &command {
let events = EventLog::read_events(&paths.events_file())?;
let state = reducer::fold(&events)?;
config::apply_validated_patch(&state.config, patch)?;
}
control::enqueue(&paths, &command)?;
Ok((StatusCode::ACCEPTED, Json(json!({ "queued": true }))))
}
fn terminal_status_from_tail(
paths: &MissionPaths,
) -> kranz_engine::error::Result<Option<MissionStatus>> {
const TAIL_WINDOW_BYTES: u64 = 64 * 1024;
let events = EventLog::read_tail_events(&paths.events_file(), TAIL_WINDOW_BYTES)?;
Ok(events.iter().rev().find_map(|e| match e.kind {
EventKind::MissionCompleted {} => Some(MissionStatus::Complete),
EventKind::MissionFailed { .. } => Some(MissionStatus::Failed),
EventKind::MissionAbandoned { .. } => Some(MissionStatus::Abandoned),
_ => None,
}))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReviseBody {
instructions: String,
}
pub(crate) async fn post_revise(
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
Json(body): Json<ReviseBody>,
) -> Result<impl IntoResponse, ApiError> {
let instructions = body.instructions.trim();
if instructions.is_empty() {
return Err(ApiError::bad_request(
"revision instructions must not be empty",
));
}
let paths = require_revisable_mission(&server, &id)?;
control::enqueue(
&paths,
&ControlCommand::RequestRevision {
instructions: instructions.to_string(),
},
)?;
Ok((StatusCode::ACCEPTED, Json(json!({ "queued": true }))))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RevisionDecisionBody {
revision: u32,
}
pub(crate) async fn post_revision_approve(
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
Json(body): Json<RevisionDecisionBody>,
) -> Result<impl IntoResponse, ApiError> {
let paths = require_pending_revision(&server, &id, body.revision)?;
control::enqueue(
&paths,
&ControlCommand::ApproveRevision {
revision: body.revision,
},
)?;
Ok((StatusCode::ACCEPTED, Json(json!({ "queued": true }))))
}
pub(crate) async fn post_revision_reject(
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
Json(body): Json<RevisionDecisionBody>,
) -> Result<impl IntoResponse, ApiError> {
let paths = require_pending_revision(&server, &id, body.revision)?;
control::enqueue(
&paths,
&ControlCommand::RejectRevision {
revision: body.revision,
},
)?;
Ok((StatusCode::ACCEPTED, Json(json!({ "queued": true }))))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GrantApproveBody {
command: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GrantDenyBody {
command: String,
#[serde(default = "default_grant_deny_reason")]
reason: String,
}
fn default_grant_deny_reason() -> String {
"denied by operator".to_string()
}
pub(crate) async fn post_grant_approve(
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
Json(body): Json<GrantApproveBody>,
) -> Result<impl IntoResponse, ApiError> {
let paths = require_pending_grant(&server, &id, &body.command)?;
control::enqueue(
&paths,
&ControlCommand::ApproveGrant {
command: body.command,
},
)?;
Ok((StatusCode::ACCEPTED, Json(json!({ "queued": true }))))
}
pub(crate) async fn post_grant_deny(
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
Json(body): Json<GrantDenyBody>,
) -> Result<impl IntoResponse, ApiError> {
let paths = require_pending_grant(&server, &id, &body.command)?;
control::enqueue(
&paths,
&ControlCommand::DenyGrant {
command: body.command,
reason: body.reason,
},
)?;
Ok((StatusCode::ACCEPTED, Json(json!({ "queued": true }))))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct QuestionAnswerBody {
question_id: String,
answer: String,
#[serde(default)]
option: Option<u32>,
}
pub(crate) async fn post_question_answer(
State(server): State<Arc<ServerState>>,
UrlPath(id): UrlPath<String>,
Json(body): Json<QuestionAnswerBody>,
) -> Result<impl IntoResponse, ApiError> {
let paths =
require_pending_question(&server, &id, &body.question_id, body.option, &body.answer)?;
control::enqueue(
&paths,
&ControlCommand::AnswerQuestion {
question_id: body.question_id,
answer: body.answer,
option: body.option,
},
)?;
Ok((StatusCode::ACCEPTED, Json(json!({ "queued": true }))))
}
pub(crate) fn mission_paths(server: &ServerState, id: &str) -> Result<MissionPaths, ApiError> {
if !safe_id(id) {
return Err(unknown_mission(id));
}
let paths = MissionPaths::new(&server.repo_root, id);
if paths.require_no_follow().is_err() {
return Err(unknown_mission(id));
}
Ok(paths)
}
fn safe_id(id: &str) -> bool {
MissionPaths::is_safe_id(id)
}
fn unknown_mission(id: &str) -> ApiError {
ApiError::not_found(format!("unknown mission '{id}'"))
}
fn fold_log(paths: &MissionPaths) -> Result<MissionState, String> {
let events = EventLog::read_events(&paths.events_file()).map_err(|e| e.to_string())?;
reducer::fold(&events).map_err(|e| e.to_string())
}
fn require_revisable_mission(server: &ServerState, id: &str) -> Result<MissionPaths, ApiError> {
let paths = mission_paths(server, id)?;
if !paths.events_file().is_file() {
return Err(unknown_mission(id));
}
let state = fold_log(&paths).map_err(ApiError::internal)?;
if state.mission.status == MissionStatus::Planning {
return Err(ApiError::conflict(format!(
"mission '{id}' has no approved plan to revise yet"
)));
}
if kranz_engine::mission_catalog::is_terminal_status(state.mission.status) {
return Err(ApiError::conflict(format!(
"mission '{id}' is {:?}; revision commands apply only to active missions",
state.mission.status
)));
}
Ok(paths)
}
fn require_pending_revision(
server: &ServerState,
id: &str,
revision: u32,
) -> Result<MissionPaths, ApiError> {
let paths = require_revisable_mission(server, id)?;
let state = fold_log(&paths).map_err(ApiError::internal)?;
match state.pending_revision {
Some(pending) if pending.revision == revision => Ok(paths),
Some(pending) => Err(ApiError::conflict(format!(
"mission '{id}' is awaiting revision {}, not {revision}",
pending.revision
))),
None => Err(ApiError::conflict(format!(
"mission '{id}' has no pending revision"
))),
}
}
fn require_pending_grant(
server: &ServerState,
id: &str,
command: &str,
) -> Result<MissionPaths, ApiError> {
let paths = require_revisable_mission(server, id)?;
let state = fold_log(&paths).map_err(ApiError::internal)?;
match state.pending_grant_request {
Some(pending) if pending.command == command => Ok(paths),
Some(pending) => Err(ApiError::conflict(format!(
"mission '{id}' is awaiting a grant for `{}`, not `{command}`",
pending.command
))),
None => Err(ApiError::conflict(format!(
"mission '{id}' has no pending grant request"
))),
}
}
fn require_pending_question(
server: &ServerState,
id: &str,
question_id: &str,
option: Option<u32>,
answer: &str,
) -> Result<MissionPaths, ApiError> {
let paths = require_revisable_mission(server, id)?;
let state = fold_log(&paths).map_err(ApiError::internal)?;
let Some(pending) = state
.pending_questions
.iter()
.find(|q| q.question_id == question_id)
else {
return Err(ApiError::conflict(format!(
"mission '{id}' has no open question '{question_id}'"
)));
};
if let Some(index) = option {
match pending.options.get(index as usize) {
Some(expected) if expected == answer => {}
Some(expected) => {
return Err(ApiError::conflict(format!(
"answer `{answer}` does not match option {index} (`{expected}`) of question '{question_id}'"
)))
}
None => {
return Err(ApiError::conflict(format!(
"question '{question_id}' has no option {index} (it offered {})",
pending.options.len()
)))
}
}
}
Ok(paths)
}
fn simple_line_diff(old_name: &str, new_name: &str, old: &str, new: &str) -> String {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let mut diff = format!("--- {old_name}\n+++ {new_name}\n");
let max = old_lines.len().max(new_lines.len());
for i in 0..max {
match (old_lines.get(i), new_lines.get(i)) {
(Some(a), Some(b)) if a == b => {
diff.push(' ');
diff.push_str(a);
diff.push('\n');
}
(Some(a), Some(b)) => {
diff.push('-');
diff.push_str(a);
diff.push('\n');
diff.push('+');
diff.push_str(b);
diff.push('\n');
}
(Some(a), None) => {
diff.push('-');
diff.push_str(a);
diff.push('\n');
}
(None, Some(b)) => {
diff.push('+');
diff.push_str(b);
diff.push('\n');
}
(None, None) => {}
}
}
diff
}
const MAX_ARTIFACT_BYTES: u64 = 8 * 1024 * 1024;
#[cfg(test)]
async fn read_file_or_404(
path: &Path,
not_found_msg: impl FnOnce() -> String,
) -> Result<String, ApiError> {
let path = path.to_path_buf();
let missing = not_found_msg();
crate::read_work::run(move || read_file_or_404_sync(&path, missing)).await
}
fn read_file_or_404_sync(path: &Path, missing: String) -> Result<String, ApiError> {
let file = match kranz_engine::paths::open_read_nofollow(path) {
Ok(file) => file,
Err(kranz_engine::error::EngineError::Io(e)) if e.kind() == ErrorKind::NotFound => {
return Err(ApiError::not_found(missing));
}
Err(kranz_engine::error::EngineError::InvalidState(_)) => {
return Err(ApiError::not_found(missing));
}
Err(e) => {
return Err(ApiError::internal(format!(
"failed to read {}: {e}",
path.display()
)))
}
};
kranz_engine::paths::read_regular_file_bounded(file, MAX_ARTIFACT_BYTES).map_err(|e| {
if e.kind() == ErrorKind::FileTooLarge {
ApiError {
status: axum::http::StatusCode::PAYLOAD_TOO_LARGE,
code: None,
message: format!("artifact exceeds the {MAX_ARTIFACT_BYTES}-byte read limit"),
}
} else {
ApiError::internal(format!("failed to read {}: {e}", path.display()))
}
})
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use kranz_engine::event_log::{EventLog, LockForce};
use kranz_engine::events::EventKind;
use kranz_engine::paths::MissionPaths;
use kranz_engine::types::{
Finding, GrantKind, MissionConfig, PinnedRule, Plan, PlanFeature, PlanMilestone,
RuleCitation, StandardsPin, StandardsPinSource,
};
use serde_json::Value;
use std::time::Duration;
use tempfile::TempDir;
use tower::ServiceExt;
#[tokio::test]
async fn artifact_reads_reject_oversized_and_nonregular_inputs() {
let repo = TempDir::new().unwrap();
let paths = MissionPaths::new(repo.path(), "m-bounded");
std::fs::create_dir_all(paths.mission_dir()).unwrap();
let path = paths.report_file();
std::fs::File::create(&path)
.unwrap()
.set_len(super::MAX_ARTIFACT_BYTES + 1)
.unwrap();
let error = super::read_file_or_404(&path, || "missing".into())
.await
.unwrap_err();
assert_eq!(error.status, StatusCode::PAYLOAD_TOO_LARGE);
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();
let error = super::read_file_or_404(&path, || "missing".into())
.await
.unwrap_err();
assert_eq!(error.status, StatusCode::NOT_FOUND);
std::fs::remove_dir(&path).unwrap();
std::fs::write(&path, "complete report").unwrap();
assert_eq!(
super::read_file_or_404(&path, || "missing".into())
.await
.unwrap(),
"complete report"
);
}
async fn body_json(response: axum::response::Response) -> Value {
let bytes = response.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
fn get(uri: &str) -> Request<Body> {
Request::builder().uri(uri).body(Body::empty()).unwrap()
}
fn seed_mission(repo_root: &std::path::Path, id: &str, kinds: Vec<EventKind>) {
let paths = MissionPaths::new(repo_root, id);
let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
for kind in kinds {
log.append(kind).unwrap();
}
}
fn created(goal: &str) -> EventKind {
EventKind::MissionCreated {
goal: goal.into(),
base_branch: "main".into(),
mission_branch: "kranz/mission-x".into(),
config: MissionConfig::default(),
}
}
fn post_json(uri: &str, body: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
#[tokio::test]
async fn flight_rules_dashboard_standards_view_is_typed_and_hides_nonwaivable_actions() {
let tmp = TempDir::new().unwrap();
let digest = "ab".repeat(32);
let rule = |id: &str, waivable: bool| PinnedRule {
id: id.to_string(),
revision: 2,
rfc: "RFC-001".to_string(),
level: "must".to_string(),
effective_status: "enforced".to_string(),
statement: format!("statement for {id}"),
domains: vec!["security".to_string()],
stages: vec!["validation".to_string()],
when_paths: Vec::new(),
task_classes: Vec::new(),
checker: Some("gate:secure".to_string()),
waivable,
};
let rules = vec![rule("ZZ-WAIVE-001", true), rule("ZZ-LOCKED-001", false)];
let pin = StandardsPin {
pack_name: "zz-pack".to_string(),
pack_dir: "vendor/pack".to_string(),
standards_root: "standards".to_string(),
digest: digest.clone(),
source: StandardsPinSource::RepoTracked,
task_class: None,
touch_set: vec!["src/**".to_string()],
context_paths: Vec::new(),
gates: Vec::new(),
rules: rules.clone(),
};
let plan = Plan {
goal: "governed change".to_string(),
validation_contract: Vec::new(),
milestones: vec![PlanMilestone {
title: "one".to_string(),
features: vec![PlanFeature {
title: "change".to_string(),
spec: "implement".to_string(),
validation_criteria: Vec::new(),
}],
}],
considered_alternatives: None,
command_grants: Vec::new(),
touch_set: vec!["src/**".to_string()],
standards_manifest: Some(Box::new(pin)),
reviewer_independence: None,
};
let finding = |rule: &PinnedRule| Finding {
subject: format!("flight-rule:{}", rule.id),
severity: "critical".to_string(),
evidence: format!("{} failed with exact evidence", rule.id),
suggested_fix: "fix it".to_string(),
class: "standards-authoritative".to_string(),
rule: Some(RuleCitation {
id: rule.id.clone(),
revision: rule.revision,
source: "zz-pack standards".to_string(),
digest: digest.clone(),
lifecycle: rule.effective_status.clone(),
level: rule.level.clone(),
checker: rule.checker.clone(),
}),
};
seed_mission(
tmp.path(),
"m-1",
vec![
created("governed change"),
EventKind::PlanApproved {
plan,
base_sha: Some("deadbeef".to_string()),
},
EventKind::ValidationFinding {
milestone_id: "ms-1".to_string(),
run_id: kranz_engine::reducer::ENGINE_RUN_ID.to_string(),
finding: finding(&rules[0]),
},
EventKind::ValidationFinding {
milestone_id: "ms-1".to_string(),
run_id: kranz_engine::reducer::ENGINE_RUN_ID.to_string(),
finding: finding(&rules[1]),
},
],
);
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app
.oneshot(get("/api/missions/m-1/standards"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["manifest"]["digest"], digest);
assert_eq!(body["coverage"]["rules"][0]["disposition"], "failed");
assert_eq!(body["waiverCandidates"].as_array().unwrap().len(), 1);
assert_eq!(body["waiverCandidates"][0]["rule"]["id"], "ZZ-WAIVE-001");
assert!(body["waiverCandidates"][0]["findingEvidence"]
.as_str()
.unwrap()
.contains("exact evidence"));
}
#[tokio::test]
async fn hook_status_signal_endpoint_records_serves_and_never_touches_state() {
let tmp = TempDir::new().unwrap();
seed_mission(
tmp.path(),
"m-1",
vec![created("terminal"), EventKind::MissionCompleted {}],
);
kranz_engine::hook_status::register(tmp.path(), "m-1", "r-1", "tok-1", chrono::Utc::now())
.unwrap();
let events_before =
std::fs::read(MissionPaths::new(tmp.path(), "m-1").events_file()).unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app
.clone()
.oneshot(post_json(
"/api/hook-status",
&serde_json::json!({
"token": "tok-1",
"missionId": "m-1",
"runId": "r-1",
"signal": "needs-input",
"detail": "Shell was refused",
})
.to_string(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
let response = app
.clone()
.oneshot(get("/api/missions/m-1/hook-status"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["authoritative"], false);
let runs = body["runs"].as_array().unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0]["runId"], "r-1");
assert_eq!(runs[0]["signal"]["signal"], "needs-input");
assert_eq!(runs[0]["signal"]["detail"], "Shell was refused");
assert!(
runs[0]["signal"]["receivedAt"].as_str().is_some(),
"{runs:?}"
);
let response = app.oneshot(get("/api/missions/m-1/state")).await.unwrap();
let state = body_json(response).await;
assert_eq!(state["mission"]["status"], "complete");
let events_after =
std::fs::read(MissionPaths::new(tmp.path(), "m-1").events_file()).unwrap();
assert_eq!(events_before, events_after);
}
#[tokio::test]
async fn hook_status_signal_endpoint_rejects_untrusted_payloads() {
let tmp = TempDir::new().unwrap();
seed_mission(tmp.path(), "m-1", vec![created("x")]);
kranz_engine::hook_status::register(tmp.path(), "m-1", "r-1", "tok-1", chrono::Utc::now())
.unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app
.clone()
.oneshot(post_json(
"/api/hook-status",
&serde_json::json!({
"token": "tok-WRONG",
"missionId": "m-1",
"runId": "r-1",
"signal": "running",
})
.to_string(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let response = app
.clone()
.oneshot(post_json(
"/api/hook-status",
&serde_json::json!({
"token": "tok-1",
"missionId": "m-1",
"runId": "r-9",
"signal": "running",
})
.to_string(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = app
.clone()
.oneshot(post_json(
"/api/hook-status",
&serde_json::json!({
"token": "tok-1",
"missionId": "../m-1",
"runId": "r-1",
"signal": "running",
})
.to_string(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = app
.clone()
.oneshot(post_json(
"/api/hook-status",
&serde_json::json!({
"token": "tok-1",
"missionId": "m-1",
"runId": "r-1",
"signal": "complete",
})
.to_string(),
))
.await
.unwrap();
assert!(
response.status().is_client_error(),
"an out-of-vocabulary signal must be rejected: {}",
response.status()
);
let response = app
.clone()
.oneshot(post_json("/api/hook-status", "{not json"))
.await
.unwrap();
assert!(response.status().is_client_error());
let oversized = format!(
"{{\"token\":\"tok-1\",\"missionId\":\"m-1\",\"runId\":\"r-1\",\"signal\":\"running\",\"detail\":\"{}\"}}",
"x".repeat(kranz_engine::hook_status::SIGNAL_BODY_MAX_BYTES)
);
let response = app
.clone()
.oneshot(post_json("/api/hook-status", &oversized))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
let views = kranz_engine::hook_status::read_mission_signals(tmp.path(), "m-1");
assert!(views.iter().all(|v| v.signal.is_none()), "{views:?}");
}
#[tokio::test]
async fn hook_status_signal_post_is_exempt_from_the_mutation_token_gate() {
let tmp = TempDir::new().unwrap();
seed_mission(tmp.path(), "m-1", vec![created("x")]);
kranz_engine::hook_status::register(tmp.path(), "m-1", "r-1", "tok-1", chrono::Utc::now())
.unwrap();
let app = crate::router_with_token(
tmp.path().to_path_buf(),
None,
crate::MutationAuthority::new("serve-secret").unwrap(),
);
let response = app
.clone()
.oneshot(post_json(
"/api/hook-status",
&serde_json::json!({
"token": "tok-1",
"missionId": "m-1",
"runId": "r-1",
"signal": "running",
})
.to_string(),
))
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::ACCEPTED,
"the per-run capability token authenticates the lane, not the serve token"
);
let response = app
.oneshot(post_json(
"/api/missions/m-1/revise",
"{\"instructions\":\"x\"}",
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn hook_status_signal_server_writes_nothing_into_the_tracked_tree() {
let tmp = TempDir::new().unwrap();
seed_mission(tmp.path(), "m-1", vec![created("x")]);
kranz_engine::hook_status::register(tmp.path(), "m-1", "r-1", "tok-1", chrono::Utc::now())
.unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app
.clone()
.oneshot(post_json(
"/api/hook-status",
&serde_json::json!({
"token": "tok-1",
"missionId": "m-1",
"runId": "r-1",
"signal": "turn-finished",
})
.to_string(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
let response = app
.oneshot(get("/api/missions/m-1/hook-status"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
!tmp.path().join(".cursor").exists(),
"no cursor hook config may appear in the repo tree"
);
assert!(
kranz_engine::hook_status::hook_status_dir(tmp.path()).is_dir(),
"the projection is the lane's only write"
);
}
#[tokio::test]
async fn outcomes_endpoint_empty_repo_returns_zeroed_defaults() {
let tmp = TempDir::new().unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get("/api/missions/outcomes")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["autonomyRatio"]["closedMissions"], 0);
let buckets = body["grantLatency"]["buckets"].as_array().unwrap();
assert_eq!(buckets.len(), 4);
assert!(buckets.iter().all(|b| b["count"] == 0));
assert_eq!(body["escalations"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn outcomes_endpoint_route_is_not_swallowed_by_mission_id_routes() {
let tmp = TempDir::new().unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get("/api/missions/outcomes")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert!(body.get("autonomyRatio").is_some());
assert!(body.get("error").is_none());
}
#[tokio::test]
async fn outcomes_endpoint_seeded_repo_populates_buckets_and_escalations() {
let tmp = TempDir::new().unwrap();
seed_mission(
tmp.path(),
"m-1",
vec![
created("seeded"),
EventKind::GrantRequested {
milestone_id: "ms-1".into(),
kind: GrantKind::Command,
command: "cargo test".into(),
},
EventKind::GrantApproved {
kind: GrantKind::Command,
command: "cargo test".into(),
},
EventKind::PlanRevisionProposed {
revision: 1,
plan: sample_plan(),
instructions: "add tests".into(),
},
EventKind::PlanRevised {
revision: 1,
plan: sample_plan(),
},
EventKind::MissionCompleted {},
],
);
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get("/api/missions/outcomes")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["autonomyRatio"]["closedMissions"], 1);
assert_eq!(body["autonomyRatio"]["totalInterventions"], 2);
let buckets = body["grantLatency"]["buckets"].as_array().unwrap();
assert_eq!(buckets.len(), 4);
let total_bucketed: i64 = buckets.iter().map(|b| b["count"].as_i64().unwrap()).sum();
assert_eq!(total_bucketed, 1);
assert_eq!(body["grantLatency"]["totalDecided"], 1);
let escalations = body["escalations"].as_array().unwrap();
assert!(!escalations.is_empty());
let grant_row = escalations
.iter()
.find(|e| e["kind"] == "grant")
.expect("grant escalation row present");
assert_eq!(grant_row["missionId"], "m-1");
assert_eq!(grant_row["summary"], "cargo test");
assert_eq!(grant_row["decision"], "approved");
assert!(grant_row["latencyMs"].is_number());
let revision_row = escalations
.iter()
.find(|e| e["kind"] == "revision")
.expect("revision escalation row present");
assert_eq!(revision_row["missionId"], "m-1");
assert_eq!(revision_row["summary"], "add tests");
assert_eq!(revision_row["decision"], "accepted (rev 1)");
}
#[tokio::test]
async fn escalation_metrics_endpoint_empty_repo_returns_none_rates() {
let tmp = TempDir::new().unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get("/api/escalation-metrics")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["autonomy"]["closedMissions"], 0);
assert!(body["autonomy"]["zeroInterventionShare"].is_null());
assert_eq!(body["rubberStamp"]["decidedGrants"], 0);
assert!(body["rubberStamp"]["p50Ms"].is_null());
assert_eq!(body["falseGreens"]["completedMissions"], 0);
assert!(body["falseGreens"]["falseGreenRate"].is_null());
assert_eq!(body["ledger"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn flight_rules_metrics_endpoint_empty_repo_is_machine_readable() {
let tmp = TempDir::new().unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get("/api/standards-metrics")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["minimumSamples"], 5);
assert!(body["definitions"]
.as_array()
.is_some_and(|rows| !rows.is_empty()));
assert_eq!(body["rules"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn escalation_metrics_endpoint_seeded_repo_joins_traced_defects() {
let tmp = TempDir::new().unwrap();
seed_mission(
tmp.path(),
"m-1",
vec![
created("seeded"),
EventKind::GrantRequested {
milestone_id: "ms-1".into(),
kind: GrantKind::Command,
command: "cargo test".into(),
},
EventKind::GrantApproved {
kind: GrantKind::Command,
command: "cargo test".into(),
},
EventKind::MissionCompleted {},
],
);
seed_mission(
tmp.path(),
"m-2",
vec![created("clean"), EventKind::MissionCompleted {}],
);
let tickets = kranz_engine::ticket::Ticket::tickets_dir(tmp.path());
std::fs::create_dir_all(&tickets).unwrap();
std::fs::write(
tickets.join("defect-regression.md"),
"---\ntitle: Regression\ntraced-from-mission: m-1\n---\n\n## Goal\nfix\n",
)
.unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get("/api/escalation-metrics")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["autonomy"]["closedMissions"], 2);
assert_eq!(body["autonomy"]["zeroInterventionMissions"], 1);
assert_eq!(body["autonomy"]["zeroInterventionShare"], 0.5);
assert_eq!(body["autonomy"]["completed"]["missions"], 2);
assert_eq!(body["autonomy"]["completed"]["zeroIntervention"], 1);
assert_eq!(body["rubberStamp"]["decidedGrants"], 1);
assert_eq!(body["rubberStamp"]["underTenSeconds"], 1);
assert!(body["rubberStamp"]["p50Ms"].is_number());
assert_eq!(body["falseGreens"]["completedMissions"], 2);
assert_eq!(body["falseGreens"]["falseGreens"], 1);
assert_eq!(body["falseGreens"]["falseGreenRate"], 0.5);
assert_eq!(body["falseGreens"]["withInterventions"]["falseGreens"], 1);
assert_eq!(body["falseGreens"]["zeroIntervention"]["falseGreens"], 0);
assert_eq!(
body["falseGreens"]["tracedDefects"][0]["ticket"],
"defect-regression"
);
assert_eq!(body["falseGreens"]["tracedDefects"][0]["missionId"], "m-1");
let ledger = body["ledger"].as_array().unwrap();
assert_eq!(ledger.len(), 1);
assert_eq!(ledger[0]["kind"], "grant");
assert_eq!(ledger[0]["missionId"], "m-1");
assert_eq!(ledger[0]["milestoneId"], "ms-1");
assert_eq!(ledger[0]["ask"], "command: cargo test");
assert_eq!(ledger[0]["decision"], "approved");
assert!(ledger[0]["latencyMs"].is_number());
}
#[tokio::test]
async fn outcomes_report_cost_per_merged_endpoint_defaults_and_validates_window() {
let tmp = TempDir::new().unwrap();
seed_mission(
tmp.path(),
"m-1",
vec![created("seeded"), EventKind::MissionCompleted {}],
);
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app
.clone()
.oneshot(get("/api/cost-per-merged-change"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["windowDays"], 30);
assert_eq!(body["closedInWindow"], 1);
assert_eq!(body["mergedChanges"], 0);
assert!(body["usdPerMergedChange"].is_null());
assert_eq!(body["zeroInterventionShare"], 1.0);
let response = app
.clone()
.oneshot(get("/api/cost-per-merged-change?windowDays=7"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["windowDays"], 7);
let response = app
.oneshot(get("/api/cost-per-merged-change?windowDays=abc"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn outcomes_report_outcomes_endpoint_carries_the_new_fold_sections() {
let tmp = TempDir::new().unwrap();
seed_mission(
tmp.path(),
"m-1",
vec![
created("do the thing\n\n## Task class\nexecution-class\n"),
EventKind::GrantRequested {
milestone_id: "ms-1".into(),
kind: GrantKind::Command,
command: "cargo test".into(),
},
EventKind::GrantApproved {
kind: GrantKind::Command,
command: "cargo test".into(),
},
EventKind::MissionCompleted {},
],
);
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get("/api/missions/outcomes")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
let classes = body["taskClasses"].as_array().unwrap();
assert_eq!(classes.len(), 1);
assert_eq!(classes[0]["taskClass"], "execution-class");
assert_eq!(classes[0]["missions"], 1);
assert_eq!(classes[0]["advisorInvocations"], 1);
assert_eq!(body["rubberStamp"]["thresholdMs"], 10_000);
assert_eq!(body["rubberStamp"]["flagged"], 1);
assert_eq!(body["rubberStamp"]["approvedDecisions"], 1);
let grant = body["escalations"]
.as_array()
.unwrap()
.iter()
.find(|r| r["kind"] == "grant")
.unwrap()
.clone();
assert_eq!(grant["rubberStamp"], true);
}
#[tokio::test]
async fn comparison_metrics_outcomes_endpoint_serves_the_section() {
let tmp = TempDir::new().unwrap();
seed_mission(
tmp.path(),
"m-1",
vec![created("seeded"), EventKind::MissionCompleted {}],
);
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get("/api/missions/outcomes")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
let comparison = &body["comparison"];
assert_eq!(comparison["windowDays"], 30);
assert!(comparison["assistedChangeShare"]["definition"]
.as_str()
.unwrap()
.contains("agent-involved by construction"));
assert!(comparison["defectDensity"]["definition"]
.as_str()
.unwrap()
.contains("traced-from-mission frontmatter"));
assert!(comparison["defectResolutionTime"]["dependency"]
.as_str()
.unwrap()
.contains("open/close timestamps"));
}
#[tokio::test]
async fn workspace_endpoint_surfaces_workspace_lifecycle_present_and_absent() {
let tmp = TempDir::new().unwrap();
seed_mission(
tmp.path(),
"m-1",
vec![
created("lifecycle"),
EventKind::WorkspaceTeardown {
mode: "hibernate".into(),
state: Some("stopped".into()),
},
],
);
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app
.oneshot(get("/api/missions/m-1/workspace"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["workspaceLifecycle"]["state"], "stopped");
assert!(
body["workspaceLifecycle"]["ts"].as_str().is_some(),
"the transition ts rides the field (the workspace-hours anchor): {body}"
);
seed_mission(
tmp.path(),
"m-2",
vec![
created("no-lifecycle"),
EventKind::WorkspaceTeardown {
mode: "keep".into(),
state: None,
},
],
);
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app
.oneshot(get("/api/missions/m-2/workspace"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert!(
body["workspaceLifecycle"].is_null(),
"null when no teardown carried an outcome: {body}"
);
}
fn sample_plan() -> kranz_engine::types::Plan {
kranz_engine::types::Plan {
goal: "g".into(),
validation_contract: vec![],
milestones: vec![],
considered_alternatives: None,
command_grants: vec![],
touch_set: vec![],
standards_manifest: None,
reviewer_independence: None,
}
}
#[tokio::test]
async fn list_missions_rejects_catalog_ids_with_traversal() {
let tmp = TempDir::new().unwrap();
let missions_dir = tmp.path().join(".kranz").join("missions");
std::fs::create_dir_all(&missions_dir).unwrap();
std::fs::write(
missions_dir.join("index.md"),
"# Kranz missions\n\n\
- 2026-07-28 · [../../../tmp/evil](../../../tmp/evil/plan.md) — traversal\n\
- 2026-07-28 · [m-ghost](m-ghost/plan.md) — ghost\n",
)
.unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get("/api/missions")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
let ids: Vec<&str> = body
.as_array()
.unwrap()
.iter()
.filter_map(|row| row["id"].as_str())
.collect();
assert_eq!(
ids,
vec!["m-ghost"],
"traversal catalog id rejected, legit ghost kept: {ids:?}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn list_missions_surfaces_a_symlinked_mission_dir_as_an_error_row() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let elsewhere = TempDir::new().unwrap();
seed_mission(elsewhere.path(), "m-evil", vec![created("other repo goal")]);
let missions_dir = tmp.path().join(".kranz").join("missions");
std::fs::create_dir_all(&missions_dir).unwrap();
symlink(
elsewhere
.path()
.join(".kranz")
.join("missions")
.join("m-evil"),
missions_dir.join("m-evil"),
)
.unwrap();
std::fs::write(
missions_dir.join("index.md"),
"# Kranz missions\n\n- 2026-07-28 · [m-evil](m-evil/plan.md) — evil\n",
)
.unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get("/api/missions")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
let row = body
.as_array()
.unwrap()
.iter()
.find(|row| row["id"] == "m-evil")
.expect("the symlinked mission surfaces as an error row");
assert_eq!(row["status"], "failed", "{row}");
assert!(
row["error"].as_str().unwrap().contains("refusing"),
"clear refusal: {row}"
);
assert!(
row.get("goal").is_none(),
"nothing read through the symlink: {row}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn mission_state_refuses_a_symlinked_mission_dir() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let elsewhere = TempDir::new().unwrap();
seed_mission(elsewhere.path(), "m-evil", vec![created("other repo goal")]);
let missions_dir = tmp.path().join(".kranz").join("missions");
std::fs::create_dir_all(&missions_dir).unwrap();
symlink(
elsewhere
.path()
.join(".kranz")
.join("missions")
.join("m-evil"),
missions_dir.join("m-evil"),
)
.unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app
.oneshot(get("/api/missions/m-evil/state"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[cfg(unix)]
#[tokio::test]
async fn mission_leaf_reads_refuse_a_symlinked_file() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
seed_mission(tmp.path(), "m-1", vec![created("goal")]);
let token_file = tmp.path().join(".kranz").join("serve.token");
std::fs::write(&token_file, "super-secret-mutation-token").unwrap();
let paths = MissionPaths::new(tmp.path(), "m-1");
symlink(&token_file, paths.plan_md_file()).unwrap();
symlink(&token_file, paths.report_file()).unwrap();
symlink(&token_file, paths.plan_file()).unwrap();
for uri in [
"/api/missions/m-1/plan.md",
"/api/missions/m-1/report.md",
"/api/missions/m-1/plan",
] {
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get(uri)).await.unwrap();
assert_ne!(
response.status(),
StatusCode::OK,
"{uri} must refuse a symlinked leaf"
);
let body = body_json(response).await;
assert!(
!body.to_string().contains("super-secret-mutation-token"),
"{uri} leaked the symlink target: {body}"
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn ticket_reads_refuse_a_symlinked_file() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let token_file = tmp.path().join(".kranz").join("serve.token");
std::fs::create_dir_all(tmp.path().join(".kranz")).unwrap();
std::fs::write(&token_file, "super-secret-mutation-token").unwrap();
let tickets = tmp.path().join(".kranz").join("tickets");
std::fs::create_dir_all(&tickets).unwrap();
symlink(&token_file, tickets.join("leak.md")).unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get("/api/tickets/leak")).await.unwrap();
assert_ne!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert!(
!body.to_string().contains("super-secret-mutation-token"),
"ticket read leaked the symlink target: {body}"
);
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get("/api/tickets")).await.unwrap();
let body = body_json(response).await;
assert!(
!body.to_string().contains("super-secret-mutation-token"),
"ticket listing leaked the symlink target: {body}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn missions_index_read_refuses_a_symlink() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let missions_dir = tmp.path().join(".kranz").join("missions");
std::fs::create_dir_all(&missions_dir).unwrap();
let token_file = tmp.path().join(".kranz").join("serve.token");
std::fs::write(&token_file, "super-secret-mutation-token").unwrap();
symlink(&token_file, missions_dir.join("index.md")).unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
let response = app.oneshot(get("/api/missions")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert!(
!body.to_string().contains("super-secret-mutation-token"),
"the catalog read followed a symlink: {body}"
);
}
#[tokio::test]
async fn cost_per_merged_change_window_days_bound_over_max_gets_400() {
let tmp = TempDir::new().unwrap();
let app = crate::router(tmp.path().to_path_buf(), None);
for raw in [
format!(
"{}",
kranz_engine::outcomes::MAX_MERGED_CHANGE_WINDOW_DAYS + 1
),
u64::MAX.to_string(),
] {
let response = app
.clone()
.oneshot(get(&format!(
"/api/cost-per-merged-change?windowDays={raw}"
)))
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"windowDays={raw} must be a 400, never a crash"
);
}
let response = app
.oneshot(get(&format!(
"/api/cost-per-merged-change?windowDays={}",
kranz_engine::outcomes::MAX_MERGED_CHANGE_WINDOW_DAYS
)))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(
body["windowDays"],
kranz_engine::outcomes::MAX_MERGED_CHANGE_WINDOW_DAYS
);
}
}