use std::sync::{Arc, Mutex};
use axum::{
Json, Router,
extract::{DefaultBodyLimit, Path, State},
http::StatusCode,
response::Html,
routing::{get, post},
};
use exo_core::{ExoError, Timestamp, hlc::HybridClock};
use serde::{Deserialize, Serialize};
pub type SharedForgeState = Arc<Mutex<ForgeState>>;
const MAX_FORGE_ACTIVITY_LOG_ENTRIES: usize = 256;
const MAX_FORGE_LOG_MESSAGE_BYTES: usize = 4_096;
const MAX_FORGE_API_BODY_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForgeTask {
pub id: u32,
pub phase: u32,
pub phase_name: String,
pub title: String,
pub description: String,
pub spec_section: String,
pub status: TaskStatus,
pub agent: Option<String>,
pub escalation: EscalationLevel,
pub depends_on_phase: Option<u32>,
pub started_at: Option<Timestamp>,
pub completed_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TaskStatus {
Queued,
Assigned,
InProgress,
Review,
Complete,
Blocked,
Escalated,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EscalationLevel {
None,
Council,
AiIrb,
Human,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivityEntry {
pub timestamp: Timestamp,
pub message: String,
pub task_id: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForgeStats {
pub total: u32,
pub queued: u32,
pub assigned: u32,
pub in_progress: u32,
pub review: u32,
pub complete: u32,
pub blocked: u32,
pub escalated: u32,
pub percent_complete_basis_points: u32,
pub phases: Vec<PhaseStats>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhaseStats {
pub phase: u32,
pub name: String,
pub total: u32,
pub complete: u32,
pub percent_basis_points: u32,
}
pub struct ForgeState {
pub spec_name: String,
#[allow(dead_code)]
pub spec_path: String,
pub tasks: Vec<ForgeTask>,
pub activity_log: Vec<ActivityEntry>,
#[allow(dead_code)]
pub started_at: Timestamp,
clock: HybridClock,
}
fn serialize_dashboard_json<T: Serialize>(
field: &'static str,
value: &T,
) -> Result<String, StatusCode> {
let json = serde_json::to_string(value).map_err(|error| {
tracing::error!(
field,
err = %error,
"failed to serialize ExoForge dashboard state"
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(json
.replace('<', "\\u003c")
.replace('>', "\\u003e")
.replace('&', "\\u0026")
.replace('\u{2028}', "\\u2028")
.replace('\u{2029}', "\\u2029"))
}
#[derive(Serialize, Deserialize)]
pub struct StatusUpdate {
pub status: TaskStatus,
}
#[derive(Deserialize)]
pub struct AgentAssignment {
pub agent: String,
}
#[derive(Deserialize)]
pub struct EscalateRequest {
pub level: EscalationLevel,
pub reason: String,
}
#[derive(Deserialize)]
pub struct LogEntry {
pub message: String,
pub task_id: Option<u32>,
}
impl ForgeState {
pub fn new_zerodentity() -> Result<Self, ExoError> {
Self::new_zerodentity_with_clock(HybridClock::new())
}
pub fn new_zerodentity_with_clock(mut clock: HybridClock) -> Result<Self, ExoError> {
let tasks = build_zerodentity_tasks();
let started = clock.now()?;
let decomposed_at = clock.now()?;
let awaiting_at = clock.now()?;
Ok(ForgeState {
spec_name: "0DENTITY-APP-SPEC.md".into(),
spec_path: "docs/0DENTITY-APP-SPEC.md".into(),
tasks,
activity_log: vec![
ActivityEntry {
timestamp: started,
message: "ExoForge initialized — spec loaded: 0DENTITY-APP-SPEC.md (2,221 lines, 14 sections)".into(),
task_id: None,
},
ActivityEntry {
timestamp: decomposed_at,
message: "Task graph decomposed: 56 tasks across 12 phases".into(),
task_id: None,
},
ActivityEntry {
timestamp: awaiting_at,
message: "Awaiting agent assignment — orchestrator monitoring".into(),
task_id: None,
},
],
started_at: started,
clock,
})
}
fn next_timestamp(&mut self) -> Result<Timestamp, StatusCode> {
self.clock
.now()
.map_err(|err| forge_clock_error("forge_state_timestamp", err))
}
pub fn stats(&self) -> ForgeStats {
let total = count_to_u32(self.tasks.len());
let queued = self.count_status(TaskStatus::Queued);
let assigned = self.count_status(TaskStatus::Assigned);
let in_progress = self.count_status(TaskStatus::InProgress);
let review = self.count_status(TaskStatus::Review);
let complete = self.count_status(TaskStatus::Complete);
let blocked = self.count_status(TaskStatus::Blocked);
let escalated = self.count_status(TaskStatus::Escalated);
let percent_complete_basis_points = percent_basis_points(complete, total);
let mut phase_map: std::collections::BTreeMap<u32, (String, u32, u32)> =
std::collections::BTreeMap::new();
for t in &self.tasks {
let entry = phase_map
.entry(t.phase)
.or_insert_with(|| (t.phase_name.clone(), 0, 0));
entry.1 += 1;
if t.status == TaskStatus::Complete {
entry.2 += 1;
}
}
let phases: Vec<PhaseStats> = phase_map
.into_iter()
.map(|(phase, (name, tot, comp))| PhaseStats {
phase,
name,
total: tot,
complete: comp,
percent_basis_points: percent_basis_points(comp, tot),
})
.collect();
ForgeStats {
total,
queued,
assigned,
in_progress,
review,
complete,
blocked,
escalated,
percent_complete_basis_points,
phases,
}
}
fn count_status(&self, status: TaskStatus) -> u32 {
count_to_u32(self.tasks.iter().filter(|t| t.status == status).count())
}
}
fn count_to_u32(value: usize) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
fn percent_basis_points(complete: u32, total: u32) -> u32 {
if total == 0 {
return 0;
}
complete.saturating_mul(10_000) / total
}
fn push_activity_log_bounded(activity_log: &mut Vec<ActivityEntry>, entry: ActivityEntry) {
let next_len = activity_log.len().saturating_add(1);
if next_len > MAX_FORGE_ACTIVITY_LOG_ENTRIES {
let remove_count = next_len - MAX_FORGE_ACTIVITY_LOG_ENTRIES;
activity_log.drain(0..remove_count).for_each(drop);
}
activity_log.push(entry);
}
fn validate_log_message_size(message: &str) -> Result<(), StatusCode> {
if message.len() > MAX_FORGE_LOG_MESSAGE_BYTES {
return Err(StatusCode::PAYLOAD_TOO_LARGE);
}
Ok(())
}
#[allow(clippy::too_many_lines, unused_assignments)]
fn build_zerodentity_tasks() -> Vec<ForgeTask> {
let mut tasks = Vec::new();
let mut id = 1u32;
macro_rules! task {
($phase:expr, $phase_name:expr, $title:expr, $desc:expr, $section:expr, $dep:expr) => {{
tasks.push(ForgeTask {
id,
phase: $phase,
phase_name: $phase_name.into(),
title: $title.into(),
description: $desc.into(),
spec_section: $section.into(),
status: TaskStatus::Queued,
agent: None,
escalation: EscalationLevel::None,
depends_on_phase: $dep,
started_at: None,
completed_at: None,
});
id += 1;
}};
}
task!(
1,
"Foundation Types",
"IdentityClaim + ClaimType + ClaimStatus",
"Create core identity claim types with 18 ClaimType variants and ClaimStatus enum",
"§2.2",
None
);
task!(
1,
"Foundation Types",
"ZerodentityScore + PolarAxes",
"Create the 8-axis polar decomposition score struct with composite and symmetry fields",
"§2.2",
None
);
task!(
1,
"Foundation Types",
"DeviceFingerprint + FingerprintSignal",
"Create device fingerprint composite type with 15 FingerprintSignal variants",
"§2.2",
None
);
task!(
1,
"Foundation Types",
"BehavioralSample + BehavioralSignalType",
"Create behavioral biometric sample types with 5 signal type variants",
"§2.2",
None
);
task!(
1,
"Foundation Types",
"OtpChallenge + OtpChannel + OtpState",
"Create OTP verification state machine types",
"§2.2",
None
);
task!(
2,
"Persistence Layer",
"SQLite schema + migrations",
"Create 7 new tables: identity_claims, device_fingerprints, behavioral_samples, score_snapshots, otp_challenges, peer_attestations, identity_sessions",
"§9.1",
Some(1)
);
task!(
2,
"Persistence Layer",
"CRUD operations for all tables",
"Implement insert/query/update/delete for each 0dentity table on SqliteDagStore",
"§9.1",
Some(1)
);
task!(
3,
"Scoring Engine",
"score_communication()",
"Email=35, Phone=37, both-bonus=15, extra channels capped at 13. Returns 0-100",
"§5.2",
Some(1)
);
task!(
3,
"Scoring Engine",
"score_credential_depth()",
"DisplayName=5, GovID=35, BiometricLiveness=30, ProfessionalCred=10 each (cap 30)",
"§5.2",
Some(1)
);
task!(
3,
"Scoring Engine",
"score_device_trust()",
"Base=20, signal coverage (25 max), consistency score (40 max), multi-session bonus (15)",
"§5.2",
Some(1)
);
task!(
3,
"Scoring Engine",
"score_behavioral()",
"Base=10, signal diversity (18 max), baseline similarity (40 max), volume logarithmic (16)",
"§5.2",
Some(1)
);
task!(
3,
"Scoring Engine",
"score_network_reputation()",
"Attestations=5 each (cap 40), delegations=8 each (cap 24), challenges=12 each (cap 36)",
"§5.2",
Some(1)
);
task!(
3,
"Scoring Engine",
"score_temporal_stability()",
"Age logarithmic (cap 35), verification freshness ratio (30), renewals (20), sessions (15)",
"§5.2",
Some(1)
);
task!(
3,
"Scoring Engine",
"score_cryptographic_strength()",
"Base=15, Ed25519=25/Hybrid=40/PQ=35, rotations=8 each (cap 24), entropy=10, stale key penalty",
"§5.2",
Some(1)
);
task!(
3,
"Scoring Engine",
"score_constitutional_standing()",
"Base=10, votes=4 each (cap 20), proposals=7 each (cap 21), validator=5 each (cap 25), challenges=8 each (cap 24)",
"§5.2",
Some(1)
);
task!(
3,
"Scoring Engine",
"Composite + symmetry computation",
"Unweighted mean of 8 axes. Symmetry = 1 - (σ/μ). ZerodentityScore::compute() entry point",
"§5.3-§5.4",
Some(1)
);
task!(
4,
"Signal Collection",
"Client-side hashing protocol",
"SHA-256 hash-then-discard pipeline implemented in onboarding_ui.rs (hashValue). \n Production path: swap hashValue to BLAKE3 WASM bundle; server accepts 32-byte hex digest \n from either function. fingerprint.rs compute_composite_hash() handles server-side BLAKE3.",
"§3.3",
Some(1)
);
task!(
4,
"Signal Collection",
"Fingerprint signal collectors (JS)",
"15 collectors implemented in onboarding_ui.rs _fingerprintSignals(): \n AudioContext, BatteryStatus, CanvasRendering, ColorDepthDPR, DeviceMemory, DoNotTrack, \n FontEnumeration, HardwareConcurrency, Platform, ScreenGeometry, TimezoneLocale, \n TouchSupport, UserAgent, WebGLParameters, WebRTCLocalIPs. \n Mirrors FingerprintSignal enum in types.rs. Rust: fingerprint.rs.",
"§3.4",
Some(1)
);
task!(
4,
"Signal Collection",
"Behavioral biometric collector (JS)",
"_behavioral IIFE in onboarding_ui.rs: keystroke inter-key intervals + hold durations \n (performance.now μs), mouse velocity histogram (64-sample window), touch pressure \n (PointerEvent), scroll count. 20-bucket histogram quantization + mean/stddev. \n collectBehavioralHash() hashes the summary JSON. Rust: behavioral.rs.",
"§3.5",
Some(1)
);
task!(
5,
"Onboarding API",
"POST /claims — create identity claim",
"First claim creates DID + session token. Validates claim_hash format, stores in DAG, emits TrustReceipt. Dispatches OTP if verification_channel set",
"§7.1",
Some(2)
);
task!(
5,
"Onboarding API",
"POST /verify — OTP verification",
"HMAC comparison, attempt counting, TTL check, lockout. On success: emit VerifiedEmail/VerifiedPhone receipt, update score",
"§7.1",
Some(2)
);
task!(
5,
"Onboarding API",
"POST /verify/resend — resend OTP",
"60-second cooldown, new challenge_id, preserve attempt count across resends",
"§7.1",
Some(2)
);
task!(
5,
"Onboarding API",
"Server key endpoint removed",
"ONYX-4 R6 deleted the fabricated /api/v1/0dentity/server-key route. The removed handler wrapped a BLAKE3 digest as a public key; regression coverage asserts the route is absent.",
"§7.3",
Some(2)
);
task!(
6,
"Identity API",
"GET /score — retrieve 0dentity score",
"Implemented in api.rs get_score(). Public endpoint: returns PolarAxes, composite, symmetry, claim_count.",
"§7.2",
Some(3)
);
task!(
6,
"Identity API",
"GET /claims — list claims with filters",
"Implemented in api.rs list_claims(). Bearer-auth. Filter by status, type. Pagination via limit/offset.",
"§7.2",
Some(2)
);
task!(
6,
"Identity API",
"GET /score/history — score timeline",
"Implemented in api.rs score_history(). Time-range filter. store.rs get_score_history() backed by BTreeMap.",
"§7.2",
Some(2)
);
task!(
6,
"Identity API",
"GET /fingerprints — consistency timeline",
"Implemented in api.rs list_fingerprints(). Owner-only. Returns composite_hash, captured_ms, consistency_score, signal_count.",
"§7.2",
Some(2)
);
task!(
6,
"Identity API",
"POST /attest — peer attestation",
"Create signed Ed25519 attestation from verified DID to target DID. Reject self-attestation, duplicate, empty signature, zero signature, wrong key, tampered payload, replayed payload. Compute score impact",
"§7.2",
Some(5)
);
task!(
6,
"Identity API",
"DELETE /:did — right to erasure",
"Revoke sessions, mark claims Revoked, zero score snapshots, tombstone DAG nodes, emit erasure receipt",
"§11.4",
Some(5)
);
task!(
7,
"Onboarding UI",
"Landing page + onboarding shell",
"Self-contained HTML at /0dentity. Dark theme, progress indicator, CTA 'Begin your proof'",
"§4.1",
Some(4)
);
task!(
7,
"Onboarding UI",
"Name input step",
"Form with name field, BLAKE3 client-hash, Ed25519 session key generation, behavioral capture, mini polar graph",
"§4.2",
Some(4)
);
task!(
7,
"Onboarding UI",
"Email input + OTP steps",
"Email form submits claim hash with no server-key dependency. 6-digit auto-advance input, countdown timer, resend cooldown",
"§4.3-§4.4",
Some(4)
);
task!(
7,
"Onboarding UI",
"Phone input + OTP steps",
"Country picker, E.164 format, SMS OTP with 3-min TTL, metadata disclosure table on completion",
"§4.5-§4.6",
Some(4)
);
task!(
7,
"Onboarding UI",
"Score reveal with animated polar graph",
"Full-bleed animated radar graph, sequential axis animation (200ms each), composite counter, 'View My Dashboard' CTA",
"§4.7",
Some(8)
);
task!(
8,
"Polar Graph",
"SVG radar chart renderer",
"PolarGraph class: 8-axis SVG with concentric grid rings, axis labels, max polygon outline, score polygon fill, vertex dots",
"§6.1",
None
);
task!(
8,
"Polar Graph",
"Animation engine",
"animateTo() with cubic ease-in-out, per-axis interpolation, requestAnimationFrame loop, configurable duration",
"§6.1",
None
);
task!(
8,
"Polar Graph",
"Interactive behaviors",
"Hover axis → tooltip with score + contributing claims. Click axis → detail panel. Hover polygon → composite + symmetry. Responsive breakpoints",
"§6.1",
None
);
task!(
9,
"Dashboard",
"Dashboard layout + structure",
"Self-contained HTML at /0dentity/dashboard. Grid layout: polar graph | score breakdown | claims | history | growth | fingerprints",
"§8.1-§8.2",
Some(8)
);
task!(
9,
"Dashboard",
"Polar graph integration",
"Embed PolarGraph instance, poll /score every 5s, animate to new values on change",
"§8.3-§8.4",
Some(8)
);
task!(
9,
"Dashboard",
"Claims table",
"Sortable table: type, hash (truncated), status (colored badge), verified timestamp (relative), expiry. Click for detail",
"§8.2",
Some(6)
);
task!(
9,
"Dashboard",
"Score history sparkline",
"Inline SVG sparkline showing composite score over time. Poll /score/history with daily resolution",
"§8.2",
Some(6)
);
task!(
9,
"Dashboard",
"Growth actions panel",
"Four action cards: Add Gov ID (+35 cred), Request Attestation (+5 network), Cast Vote (+4 constitutional), Rotate Key (+8 crypto). Each links to relevant flow",
"§8.5",
Some(6)
);
task!(
9,
"Dashboard",
"Fingerprint consistency panel",
"Session-over-session consistency bars. Poll /fingerprints endpoint. Show composite hash (truncated) per session",
"§8.2",
Some(6)
);
task!(
10,
"Integration",
"Claim → DAG node pipeline",
"Every IdentityClaim creates a DagNode: CBOR-encode claim, BLAKE3 hash, link to DAG tips, sign with session key, store.put()",
"§10.1",
Some(5)
);
task!(
10,
"Integration",
"Verification → TrustReceipt emission",
"On OTP success: TrustReceipt with action_type 'claim_verified:<type>', outcome Executed, authority chain hash",
"§10.2",
Some(5)
);
task!(
10,
"Integration",
"Passport API extension",
"Add zerodentity_score: Option<ZerodentityScore> to PassportResponse. Populate from latest score snapshot",
"§10.3",
Some(6)
);
task!(
10,
"Integration",
"Sentinel checks: ScoreIntegrity + OtpCleanup",
"ScoreIntegrity: random DID recomputation ε=0.001. OtpCleanup: expired challenges state != Pending",
"§10.4",
Some(3)
);
task!(
10,
"Integration",
"Telegram adjutant commands",
"/0dentity <did> summary, /0dentity-alerts anomalies, inline button [View Full Score]",
"§10.5",
Some(6)
);
task!(
11,
"Test Suite",
"Type serialization roundtrip tests",
"Serde roundtrip for every 0dentity type. ClaimType equality. ClaimStatus transition validity",
"§12.2",
Some(1)
);
task!(
11,
"Test Suite",
"Scoring engine determinism tests",
"Same claims → same score. Each axis: zero/minimal/maximal claims. Composite arithmetic. Symmetry for uniform/skewed/zero",
"§12.2",
Some(3)
);
task!(
11,
"Test Suite",
"API endpoint integration tests",
"POST /claims creates DID. POST /verify success/failure/expiry/lockout. GET /score found/404. GET /claims with filters",
"§12.2",
Some(6)
);
task!(
11,
"Test Suite",
"Store + migration tests",
"CRUD all tables. Index usage. Migration idempotency. Schema version check",
"§12.2",
Some(2)
);
task!(
11,
"Test Suite",
"Fingerprint + behavioral tests",
"Consistency: identical=1.0, different=0.0, partial=intermediate. Histogram quantization. Empty sample handling",
"§12.2",
Some(4)
);
task!(
12,
"Ops Readiness",
"Environment variable configuration",
"17 env vars: ZERODENTITY_ENABLED, OTP TTLs, snapshot interval, SMTP config, SMS provider config",
"§13.1",
Some(5)
);
task!(
12,
"Ops Readiness",
"Prometheus metrics integration",
"Counters: claims_total, otp_verifications_total. Histogram: score_composite. Gauge: fingerprint_consistency_avg, onboarding_completion_rate",
"§13.2",
Some(10)
);
task!(
12,
"Ops Readiness",
"main.rs wiring + startup",
"Module declaration, ZerodentityState construction, router merge, schema migration on startup, config loading",
"§10.6",
Some(10)
);
tasks
}
struct ForgeTaskListSnapshot {
spec_name: String,
tasks: Vec<ForgeTask>,
stats: ForgeStats,
}
struct ForgeDashboardSnapshot {
tasks_json: String,
stats_json: String,
log_json: String,
}
fn forge_lock_error(context: &'static str) -> StatusCode {
tracing::error!(context, "ForgeState mutex poisoned");
StatusCode::INTERNAL_SERVER_ERROR
}
fn forge_join_error(context: &'static str, error: tokio::task::JoinError) -> StatusCode {
tracing::error!(
context,
err = %error,
"ForgeState blocking adapter failed"
);
StatusCode::INTERNAL_SERVER_ERROR
}
fn forge_clock_error(context: &'static str, error: ExoError) -> StatusCode {
tracing::error!(
context,
err = %error,
"ForgeState HLC exhausted while creating timestamp"
);
StatusCode::INTERNAL_SERVER_ERROR
}
async fn read_forge_state<T, F>(
state: SharedForgeState,
context: &'static str,
read: F,
) -> Result<T, StatusCode>
where
T: Send + 'static,
F: FnOnce(&ForgeState) -> Result<T, StatusCode> + Send + 'static,
{
tokio::task::spawn_blocking(move || {
let state = state.lock().map_err(|_| forge_lock_error(context))?;
read(&state)
})
.await
.map_err(|error| forge_join_error(context, error))?
}
async fn mutate_forge_state<T, F>(
state: SharedForgeState,
context: &'static str,
mutate: F,
) -> Result<T, StatusCode>
where
T: Send + 'static,
F: FnOnce(&mut ForgeState) -> Result<T, StatusCode> + Send + 'static,
{
tokio::task::spawn_blocking(move || {
let mut state = state.lock().map_err(|_| forge_lock_error(context))?;
mutate(&mut state)
})
.await
.map_err(|error| forge_join_error(context, error))?
}
async fn load_task_list_snapshot(
state: SharedForgeState,
) -> Result<ForgeTaskListSnapshot, StatusCode> {
read_forge_state(state, "list_tasks", |state| {
Ok(ForgeTaskListSnapshot {
spec_name: state.spec_name.clone(),
tasks: state.tasks.clone(),
stats: state.stats(),
})
})
.await
}
async fn load_forge_stats(state: SharedForgeState) -> Result<ForgeStats, StatusCode> {
read_forge_state(state, "get_stats", |state| Ok(state.stats())).await
}
async fn load_activity_log(state: SharedForgeState) -> Result<Vec<ActivityEntry>, StatusCode> {
read_forge_state(state, "get_activity", |state| {
Ok(state.activity_log.clone())
})
.await
}
async fn load_dashboard_snapshot(
state: SharedForgeState,
) -> Result<ForgeDashboardSnapshot, StatusCode> {
read_forge_state(state, "serve_dashboard", |state| {
Ok(ForgeDashboardSnapshot {
tasks_json: serialize_dashboard_json("tasks", &state.tasks)?,
stats_json: serialize_dashboard_json("stats", &state.stats())?,
log_json: serialize_dashboard_json("activity_log", &state.activity_log)?,
})
})
.await
}
async fn update_task_status_state(
state: SharedForgeState,
task_id: u32,
status: TaskStatus,
) -> Result<(), StatusCode> {
mutate_forge_state(state, "update_task_status", move |state| {
let task_index = state
.tasks
.iter()
.position(|task| task.id == task_id)
.ok_or(StatusCode::NOT_FOUND)?;
let timestamp = state.next_timestamp()?;
let task = &mut state.tasks[task_index];
let old_status = task.status.clone();
let task_title = task.title.clone();
task.status = status.clone();
if status == TaskStatus::InProgress && task.started_at.is_none() {
task.started_at = Some(timestamp);
}
if status == TaskStatus::Complete {
task.completed_at = Some(timestamp);
}
push_activity_log_bounded(
&mut state.activity_log,
ActivityEntry {
timestamp,
message: format!(
"Task #{} '{}' status: {:?} -> {:?}",
task_id, task_title, old_status, status
),
task_id: Some(task_id),
},
);
Ok(())
})
.await
}
async fn assign_agent_state(
state: SharedForgeState,
task_id: u32,
agent: String,
) -> Result<(), StatusCode> {
mutate_forge_state(state, "assign_agent", move |state| {
let task_index = state
.tasks
.iter()
.position(|task| task.id == task_id)
.ok_or(StatusCode::NOT_FOUND)?;
let timestamp = state.next_timestamp()?;
let task = &mut state.tasks[task_index];
let task_title = task.title.clone();
task.agent = Some(agent.clone());
if task.status == TaskStatus::Queued {
task.status = TaskStatus::Assigned;
}
push_activity_log_bounded(
&mut state.activity_log,
ActivityEntry {
timestamp,
message: format!("Task #{} '{}' assigned to {}", task_id, task_title, agent),
task_id: Some(task_id),
},
);
Ok(())
})
.await
}
fn escalation_level_label(level: &EscalationLevel) -> &'static str {
match level {
EscalationLevel::None => "None",
EscalationLevel::Council => "Council",
EscalationLevel::AiIrb => "AI-IRB",
EscalationLevel::Human => "Human Operator",
}
}
async fn escalate_task_state(
state: SharedForgeState,
task_id: u32,
level: EscalationLevel,
reason: String,
) -> Result<&'static str, StatusCode> {
mutate_forge_state(state, "escalate_task", move |state| {
let task_index = state
.tasks
.iter()
.position(|task| task.id == task_id)
.ok_or(StatusCode::NOT_FOUND)?;
let timestamp = state.next_timestamp()?;
let task = &mut state.tasks[task_index];
task.escalation = level.clone();
task.status = TaskStatus::Escalated;
let level_str = escalation_level_label(&level);
push_activity_log_bounded(
&mut state.activity_log,
ActivityEntry {
timestamp,
message: format!("Task #{} ESCALATED to {} - {}", task_id, level_str, reason),
task_id: Some(task_id),
},
);
Ok(level_str)
})
.await
}
async fn append_log_state(
state: SharedForgeState,
message: String,
task_id: Option<u32>,
) -> Result<(), StatusCode> {
mutate_forge_state(state, "append_log", move |state| {
let timestamp = state.next_timestamp()?;
push_activity_log_bounded(
&mut state.activity_log,
ActivityEntry {
timestamp,
message,
task_id,
},
);
Ok(())
})
.await
}
async fn list_tasks(
State(state): State<SharedForgeState>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let snapshot = load_task_list_snapshot(state).await?;
Ok(Json(serde_json::json!({
"spec_name": snapshot.spec_name,
"tasks": snapshot.tasks,
"stats": snapshot.stats,
})))
}
async fn get_stats(State(state): State<SharedForgeState>) -> Result<Json<ForgeStats>, StatusCode> {
Ok(Json(load_forge_stats(state).await?))
}
async fn get_activity(
State(state): State<SharedForgeState>,
) -> Result<Json<Vec<ActivityEntry>>, StatusCode> {
Ok(Json(load_activity_log(state).await?))
}
async fn update_task_status(
State(state): State<SharedForgeState>,
Path(task_id): Path<u32>,
Json(body): Json<StatusUpdate>,
) -> Result<Json<serde_json::Value>, StatusCode> {
update_task_status_state(state, task_id, body.status).await?;
Ok(Json(serde_json::json!({ "ok": true, "task_id": task_id })))
}
async fn assign_agent(
State(state): State<SharedForgeState>,
Path(task_id): Path<u32>,
Json(body): Json<AgentAssignment>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let agent = body.agent;
assign_agent_state(state, task_id, agent.clone()).await?;
Ok(Json(
serde_json::json!({ "ok": true, "task_id": task_id, "agent": agent }),
))
}
async fn escalate_task(
State(state): State<SharedForgeState>,
Path(task_id): Path<u32>,
Json(body): Json<EscalateRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let level_str = escalate_task_state(state, task_id, body.level, body.reason).await?;
Ok(Json(
serde_json::json!({ "ok": true, "task_id": task_id, "escalation": level_str }),
))
}
async fn append_log(
State(state): State<SharedForgeState>,
Json(body): Json<LogEntry>,
) -> Result<Json<serde_json::Value>, StatusCode> {
validate_log_message_size(&body.message)?;
append_log_state(state, body.message, body.task_id).await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
async fn serve_dashboard(
State(state): State<SharedForgeState>,
) -> Result<Html<String>, StatusCode> {
let snapshot = load_dashboard_snapshot(state).await?;
Ok(Html(format!(
r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ExoForge — Build Orchestration</title>
<style>
:root {{
--bg-void: #050810;
--bg-primary: #0a0e17;
--bg-card: #111827;
--bg-elevated: #1e293b;
--bg-hover: #263049;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent: #38bdf8;
--accent-dim: rgba(56,189,248,0.12);
--green: #22c55e;
--amber: #f59e0b;
--red: #ef4444;
--purple: #a855f7;
--orange: #f97316;
--blue: #3b82f6;
--slate: #475569;
--border: rgba(148,163,184,0.08);
--radius: 8px;
--font-mono: ui-monospace,'Cascadia Code','Fira Code',Menlo,Consolas,monospace;
--font-sans: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
}}
*{{ margin:0; padding:0; box-sizing:border-box; }}
body {{
background: var(--bg-void);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
}}
/* Header */
.header {{
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(12px);
}}
.header-left {{
display: flex;
align-items: center;
gap: 16px;
}}
.logo {{
font-family: var(--font-mono);
font-size: 20px;
font-weight: 700;
color: var(--accent);
letter-spacing: 2px;
}}
.logo span {{ color: var(--text-muted); font-weight: 400; }}
.spec-badge {{
background: var(--accent-dim);
color: var(--accent);
padding: 4px 12px;
border-radius: 12px;
font-family: var(--font-mono);
font-size: 12px;
border: 1px solid rgba(56,189,248,0.2);
}}
.header-right {{
display: flex;
align-items: center;
gap: 12px;
}}
.progress-ring {{
font-family: var(--font-mono);
font-size: 24px;
font-weight: 700;
color: var(--accent);
}}
.progress-label {{
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}}
/* Stats row */
.stats-row {{
display: flex;
gap: 12px;
padding: 20px 24px;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}}
.stat-card {{
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 20px;
flex: 1;
min-width: 100px;
text-align: center;
}}
.stat-value {{
font-family: var(--font-mono);
font-size: 28px;
font-weight: 700;
line-height: 1.2;
}}
.stat-label {{
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-muted);
margin-top: 2px;
}}
/* Progress bar */
.progress-bar-container {{
padding: 0 24px 16px;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
}}
.progress-bar {{
height: 6px;
background: var(--bg-elevated);
border-radius: 3px;
overflow: hidden;
}}
.progress-fill {{
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--green));
border-radius: 3px;
transition: width 0.8s ease;
}}
/* Main content */
.content {{
display: grid;
grid-template-columns: 1fr 360px;
gap: 0;
min-height: calc(100vh - 200px);
}}
/* Phases column */
.phases-column {{
padding: 16px 24px;
overflow-y: auto;
border-right: 1px solid var(--border);
}}
.phase {{
margin-bottom: 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}}
.phase-header {{
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-elevated);
cursor: pointer;
user-select: none;
transition: background 0.15s;
}}
.phase-header:hover {{ background: var(--bg-hover); }}
.phase-title {{
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}}
.phase-title .chevron {{
color: var(--text-muted);
transition: transform 0.2s;
font-size: 12px;
}}
.phase.collapsed .chevron {{ transform: rotate(-90deg); }}
.phase.collapsed .phase-tasks {{ display: none; }}
.phase-progress {{
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
}}
.phase-bar {{
width: 80px;
height: 4px;
background: var(--bg-card);
border-radius: 2px;
overflow: hidden;
}}
.phase-bar-fill {{
height: 100%;
background: var(--accent);
transition: width 0.5s ease;
}}
/* Tasks */
.task {{
display: grid;
grid-template-columns: 32px 1fr auto auto auto;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-top: 1px solid var(--border);
transition: background 0.15s;
}}
.task:hover {{ background: rgba(56,189,248,0.03); }}
.task-id {{
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
text-align: center;
}}
.task-info {{
min-width: 0;
}}
.task-title {{
font-weight: 500;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}}
.task-desc {{
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}}
.task-agent {{
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
}}
.task-section {{
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
background: var(--bg-elevated);
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
}}
/* Status badges */
.status {{
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}}
.status-Queued {{ background: rgba(71,85,105,0.2); color: var(--slate); }}
.status-Assigned {{ background: rgba(59,130,246,0.15); color: var(--blue); }}
.status-InProgress {{ background: rgba(245,158,11,0.15); color: var(--amber); }}
.status-Review {{ background: rgba(168,85,247,0.15); color: var(--purple); }}
.status-Complete {{ background: rgba(34,197,94,0.15); color: var(--green); }}
.status-Blocked {{ background: rgba(239,68,68,0.15); color: var(--red); }}
.status-Escalated {{ background: rgba(249,115,22,0.15); color: var(--orange); }}
/* Escalation indicator */
.escalation {{
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 4px;
text-transform: uppercase;
}}
.esc-Council {{ background: rgba(168,85,247,0.2); color: var(--purple); }}
.esc-AiIrb {{ background: rgba(249,115,22,0.2); color: var(--orange); }}
.esc-Human {{ background: rgba(239,68,68,0.2); color: var(--red); }}
/* Activity log */
.log-column {{
background: var(--bg-card);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
}}
.log-header {{
padding: 12px 16px;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-muted);
background: var(--bg-elevated);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 6px;
}}
.log-header .dot {{
width: 6px; height: 6px;
background: var(--green);
border-radius: 50%;
animation: pulse 2s infinite;
}}
@keyframes pulse {{
0%, 100% {{ opacity: 1; }}
50% {{ opacity: 0.3; }}
}}
.log-entries {{
flex: 1;
overflow-y: auto;
padding: 8px 0;
}}
.log-entry {{
padding: 6px 16px;
font-size: 12px;
border-bottom: 1px solid var(--border);
line-height: 1.4;
}}
.log-entry:hover {{ background: var(--bg-hover); }}
.log-time {{
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
margin-right: 8px;
}}
.log-task-ref {{
font-family: var(--font-mono);
color: var(--accent);
font-weight: 600;
}}
/* Responsive */
@media (max-width: 900px) {{
.content {{ grid-template-columns: 1fr; }}
.log-column {{ max-height: 300px; }}
.stats-row {{ gap: 8px; }}
.stat-card {{ min-width: 70px; padding: 8px 12px; }}
.stat-value {{ font-size: 22px; }}
}}
/* Scrollbar */
::-webkit-scrollbar {{ width: 6px; }}
::-webkit-scrollbar-track {{ background: var(--bg-primary); }}
::-webkit-scrollbar-thumb {{ background: var(--bg-elevated); border-radius: 3px; }}
::-webkit-scrollbar-thumb:hover {{ background: var(--slate); }}
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<div class="logo">EXOFORGE <span>v1</span></div>
<div class="spec-badge" id="spec-badge">0DENTITY-APP-SPEC.md</div>
</div>
<div class="header-right">
<div>
<div class="progress-ring" id="progress-pct">0%</div>
<div class="progress-label">complete</div>
</div>
</div>
</div>
<div class="stats-row" id="stats-row"></div>
<div class="progress-bar-container">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width:0%"></div>
</div>
</div>
<div class="content">
<div class="phases-column" id="phases-column"></div>
<div class="log-column">
<div class="log-header"><span class="dot"></span> Activity Feed</div>
<div class="log-entries" id="log-entries"></div>
</div>
</div>
<script>
// Initial data from server-side render
let tasks = {tasks_json};
let stats = {stats_json};
let activityLog = {log_json};
const statusOrder = ['Escalated','Blocked','InProgress','Review','Assigned','Queued','Complete'];
const statusIcons = {{
Queued: '\u23F3', Assigned: '\uD83D\uDCCB', InProgress: '\u2699\uFE0F',
Review: '\uD83D\uDD0D', Complete: '\u2705', Blocked: '\uD83D\uDEAB', Escalated: '\u26A0\uFE0F'
}};
function clearChildren(el) {{
while (el.firstChild) {{
el.removeChild(el.firstChild);
}}
}}
function appendTextElement(parent, tagName, className, value) {{
const el = document.createElement(tagName);
if (className) {{
el.className = className;
}}
el.textContent = String(value ?? '');
parent.appendChild(el);
return el;
}}
function renderStats() {{
const row = document.getElementById('stats-row');
const items = [
['total', stats.total, '--text-primary'],
['queued', stats.queued, '--slate'],
['assigned', stats.assigned, '--blue'],
['active', stats.in_progress, '--amber'],
['review', stats.review, '--purple'],
['done', stats.complete, '--green'],
['blocked', stats.blocked, '--red'],
['escalated', stats.escalated, '--orange'],
];
clearChildren(row);
items.forEach(([label, value, color]) => {{
const card = document.createElement('div');
card.className = 'stat-card';
const valueEl = appendTextElement(card, 'div', 'stat-value', value);
valueEl.style.color = 'var(' + color + ')';
appendTextElement(card, 'div', 'stat-label', label);
row.appendChild(card);
}});
const progressPct = stats.percent_complete_basis_points / 100;
document.getElementById('progress-pct').textContent =
progressPct.toFixed(0) + '%';
document.getElementById('progress-fill').style.width =
progressPct.toFixed(1) + '%';
}}
function renderPhases() {{
const col = document.getElementById('phases-column');
// Group tasks by phase
const phases = {{}};
tasks.forEach(t => {{
if (!phases[t.phase]) phases[t.phase] = {{ name: t.phase_name, tasks: [] }};
phases[t.phase].tasks.push(t);
}});
clearChildren(col);
Object.entries(phases).forEach(([phaseNum, phase]) => {{
const done = phase.tasks.filter(t => t.status === 'Complete').length;
const total = phase.tasks.length;
const pct = total > 0 ? (done / total * 100).toFixed(0) : 0;
const phaseEl = document.createElement('div');
phaseEl.className = 'phase';
const header = document.createElement('div');
header.className = 'phase-header';
header.addEventListener('click', () => {{
phaseEl.classList.toggle('collapsed');
}});
const title = document.createElement('div');
title.className = 'phase-title';
appendTextElement(title, 'span', 'chevron', '\u25BC');
title.appendChild(document.createTextNode(' Phase ' + phaseNum + ': ' + String(phase.name ?? '')));
const progress = document.createElement('div');
progress.className = 'phase-progress';
appendTextElement(progress, 'span', '', done + '/' + total);
const bar = document.createElement('div');
bar.className = 'phase-bar';
const fill = document.createElement('div');
fill.className = 'phase-bar-fill';
fill.style.width = pct + '%';
bar.appendChild(fill);
progress.appendChild(bar);
appendTextElement(progress, 'span', '', pct + '%');
header.appendChild(title);
header.appendChild(progress);
phaseEl.appendChild(header);
const taskList = document.createElement('div');
taskList.className = 'phase-tasks';
phase.tasks.forEach(t => {{
const taskEl = document.createElement('div');
taskEl.className = 'task';
taskEl.dataset.taskId = String(t.id ?? '');
appendTextElement(taskEl, 'div', 'task-id', '#' + String(t.id ?? ''));
const info = document.createElement('div');
info.className = 'task-info';
appendTextElement(info, 'div', 'task-title', t.title);
appendTextElement(info, 'div', 'task-desc', t.description);
taskEl.appendChild(info);
appendTextElement(taskEl, 'div', 'task-agent', t.agent || '\u2014');
const status = appendTextElement(
taskEl,
'span',
'status status-' + String(t.status ?? ''),
(statusIcons[t.status] || '') + ' ' + String(t.status ?? '')
);
status.setAttribute('aria-label', 'status');
appendTextElement(taskEl, 'div', 'task-section', t.spec_section);
taskList.appendChild(taskEl);
}});
phaseEl.appendChild(taskList);
col.appendChild(phaseEl);
}});
}}
function renderLog() {{
const container = document.getElementById('log-entries');
// Show newest first
const entries = [...activityLog].reverse();
clearChildren(container);
entries.forEach(e => {{
const timestampMs = e.timestamp && e.timestamp.physical_ms ? e.timestamp.physical_ms : 0;
const d = new Date(timestampMs);
const time = d.toLocaleTimeString('en-US', {{ hour12: false }});
const entry = document.createElement('div');
entry.className = 'log-entry';
appendTextElement(entry, 'span', 'log-time', time);
if (e.task_id) {{
const taskRef = appendTextElement(entry, 'span', 'log-task-ref', '#' + String(e.task_id));
taskRef.appendChild(document.createTextNode(' '));
}}
entry.appendChild(document.createTextNode(String(e.message ?? '')));
container.appendChild(entry);
}});
}}
function render() {{
renderStats();
renderPhases();
renderLog();
}}
// Initial render
render();
// Poll for updates every 3 seconds
setInterval(async () => {{
try {{
const resp = await fetch('/api/v1/forge/tasks');
if (resp.ok) {{
const data = await resp.json();
tasks = data.tasks;
stats = data.stats;
}}
const logResp = await fetch('/api/v1/forge/activity');
if (logResp.ok) {{
activityLog = await logResp.json();
}}
render();
}} catch (e) {{
console.warn('ExoForge poll failed:', e.message);
const hdr = document.querySelector('.forge-header');
if (hdr) hdr.style.borderBottom = '2px solid #ef4444';
setTimeout(() => {{ if (hdr) hdr.style.borderBottom = ''; }}, 2000);
}}
}}, 3000);
</script>
</body>
</html>"##,
tasks_json = snapshot.tasks_json,
stats_json = snapshot.stats_json,
log_json = snapshot.log_json,
)))
}
pub fn exoforge_router(state: SharedForgeState) -> Router {
Router::new()
.route("/exoforge", get(serve_dashboard))
.route("/api/v1/forge/tasks", get(list_tasks))
.route("/api/v1/forge/stats", get(get_stats))
.route("/api/v1/forge/activity", get(get_activity))
.route("/api/v1/forge/tasks/:id/status", post(update_task_status))
.route("/api/v1/forge/tasks/:id/assign", post(assign_agent))
.route("/api/v1/forge/tasks/:id/escalate", post(escalate_task))
.route("/api/v1/forge/log", post(append_log))
.with_state(state)
.layer(DefaultBodyLimit::max(MAX_FORGE_API_BODY_BYTES))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn test_state() -> ForgeState {
ForgeState::new_zerodentity_with_clock(HybridClock::with_wall_clock(|| 42_000))
.expect("forge state")
}
#[test]
fn task_initialization_produces_56_tasks() {
let tasks = build_zerodentity_tasks();
assert_eq!(tasks.len(), 56);
assert!(tasks.iter().all(|t| t.status == TaskStatus::Queued));
let ids: std::collections::BTreeSet<u32> = tasks.iter().map(|t| t.id).collect();
assert_eq!(ids.len(), 56);
}
#[test]
fn phase_coverage() {
let tasks = build_zerodentity_tasks();
let phases: std::collections::BTreeSet<u32> = tasks.iter().map(|t| t.phase).collect();
assert_eq!(phases.len(), 12);
assert_eq!(*phases.iter().next().unwrap(), 1);
assert_eq!(*phases.iter().last().unwrap(), 12);
}
#[test]
fn stats_computation() {
let mut state = test_state();
let s = state.stats();
assert_eq!(s.total, 56);
assert_eq!(s.queued, 56);
assert_eq!(s.complete, 0);
assert_eq!(s.percent_complete_basis_points, 0);
state.tasks[0].status = TaskStatus::Complete;
let s2 = state.stats();
assert_eq!(s2.complete, 1);
assert_eq!(s2.percent_complete_basis_points, 178);
}
#[test]
fn state_initialization() {
let state = test_state();
assert_eq!(state.spec_name, "0DENTITY-APP-SPEC.md");
assert_eq!(state.activity_log.len(), 3);
assert_eq!(state.started_at, Timestamp::new(42_000, 0));
assert_eq!(state.activity_log[0].timestamp, Timestamp::new(42_000, 0));
assert_eq!(state.activity_log[1].timestamp, Timestamp::new(42_000, 1));
assert_eq!(state.activity_log[2].timestamp, Timestamp::new(42_000, 2));
}
#[tokio::test]
async fn dashboard_returns_html() {
let state: SharedForgeState = Arc::new(Mutex::new(test_state()));
let router = exoforge_router(state);
let req = axum::http::Request::builder()
.uri("/exoforge")
.body(axum::body::Body::empty())
.unwrap();
let resp = tower::ServiceExt::oneshot(router, req).await.unwrap();
assert_eq!(resp.status(), 200);
let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
.await
.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(html.contains("EXOFORGE"));
assert!(html.contains("0DENTITY-APP-SPEC.md"));
assert!(html.contains("Foundation Types"));
assert!(html.contains("Activity Feed"));
}
#[tokio::test]
async fn tasks_api_returns_all_tasks() {
let state: SharedForgeState = Arc::new(Mutex::new(test_state()));
let router = exoforge_router(state);
let req = axum::http::Request::builder()
.uri("/api/v1/forge/tasks")
.body(axum::body::Body::empty())
.unwrap();
let resp = tower::ServiceExt::oneshot(router, req).await.unwrap();
assert_eq!(resp.status(), 200);
let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
.await
.unwrap();
let data: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(data["stats"]["total"], 56);
}
#[tokio::test]
async fn status_update_uses_state_hlc_timestamp() {
let state: SharedForgeState = Arc::new(Mutex::new(test_state()));
let router = exoforge_router(Arc::clone(&state));
let body = serde_json::to_vec(&StatusUpdate {
status: TaskStatus::InProgress,
})
.unwrap();
let req = axum::http::Request::builder()
.method("POST")
.uri("/api/v1/forge/tasks/1/status")
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(body))
.unwrap();
let resp = tower::ServiceExt::oneshot(router, req).await.unwrap();
assert_eq!(resp.status(), 200);
let state = state.lock().unwrap();
assert_eq!(state.tasks[0].started_at, Some(Timestamp::new(42_000, 3)));
assert_eq!(
state.activity_log.last().unwrap().timestamp,
Timestamp::new(42_000, 3)
);
}
#[tokio::test]
async fn append_log_bounds_activity_log_growth() {
const EXPECTED_MAX_ACTIVITY_LOG_ENTRIES: usize = 256;
let state: SharedForgeState = Arc::new(Mutex::new(test_state()));
let router = exoforge_router(Arc::clone(&state));
for idx in 0..300u16 {
let body = serde_json::to_vec(&serde_json::json!({
"message": format!("entry-{idx}"),
"task_id": null,
}))
.unwrap();
let req = axum::http::Request::builder()
.method("POST")
.uri("/api/v1/forge/log")
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(body))
.unwrap();
let resp = tower::ServiceExt::oneshot(router.clone(), req)
.await
.unwrap();
assert_eq!(resp.status(), 200);
}
let state = state.lock().unwrap();
assert_eq!(state.activity_log.len(), EXPECTED_MAX_ACTIVITY_LOG_ENTRIES);
assert_eq!(
state.activity_log.first().unwrap().message,
"entry-44",
"oldest entries should be evicted first, after preserving a fixed-size recent log"
);
assert_eq!(state.activity_log.last().unwrap().message, "entry-299");
}
#[tokio::test]
async fn append_log_rejects_oversized_messages_before_state_mutation() {
let state: SharedForgeState = Arc::new(Mutex::new(test_state()));
let router = exoforge_router(Arc::clone(&state));
let body = serde_json::to_vec(&serde_json::json!({
"message": "x".repeat(MAX_FORGE_LOG_MESSAGE_BYTES + 1),
"task_id": null,
}))
.unwrap();
let req = axum::http::Request::builder()
.method("POST")
.uri("/api/v1/forge/log")
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(body))
.unwrap();
let resp = tower::ServiceExt::oneshot(router, req).await.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
let state = state.lock().unwrap();
assert_eq!(
state.activity_log.len(),
3,
"oversized log messages must be rejected before mutating forge state"
);
}
#[tokio::test]
async fn append_log_accepts_max_sized_message() {
let state: SharedForgeState = Arc::new(Mutex::new(test_state()));
let router = exoforge_router(Arc::clone(&state));
let message = "x".repeat(MAX_FORGE_LOG_MESSAGE_BYTES);
let body = serde_json::to_vec(&serde_json::json!({
"message": message,
"task_id": null,
}))
.unwrap();
let req = axum::http::Request::builder()
.method("POST")
.uri("/api/v1/forge/log")
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(body))
.unwrap();
let resp = tower::ServiceExt::oneshot(router, req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let state = state.lock().unwrap();
assert_eq!(state.activity_log.len(), 4);
assert_eq!(
state.activity_log.last().unwrap().message.len(),
MAX_FORGE_LOG_MESSAGE_BYTES
);
}
#[tokio::test]
async fn exoforge_router_rejects_oversized_request_body_before_state_mutation() {
let state: SharedForgeState = Arc::new(Mutex::new(test_state()));
let router = exoforge_router(Arc::clone(&state));
let body = serde_json::to_vec(&serde_json::json!({
"status": "InProgress",
"padding": "x".repeat(MAX_FORGE_API_BODY_BYTES + 1),
}))
.unwrap();
let req = axum::http::Request::builder()
.method("POST")
.uri("/api/v1/forge/tasks/1/status")
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(body))
.unwrap();
let resp = tower::ServiceExt::oneshot(router, req).await.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
let state = state.lock().unwrap();
assert_eq!(state.tasks[0].status, TaskStatus::Queued);
assert_eq!(
state.activity_log.len(),
3,
"oversized request bodies must be rejected before mutating forge state"
);
}
#[test]
fn exoforge_router_applies_explicit_request_body_limit() {
let source = include_str!("exoforge.rs");
let router = source
.split("pub fn exoforge_router")
.nth(1)
.expect("router marker present")
.split("// ─── Tests")
.next()
.expect("tests marker present");
assert!(
router.contains("DefaultBodyLimit::max(MAX_FORGE_API_BODY_BYTES)"),
"ExoForge routes must carry an explicit local body cap"
);
}
struct FailingSerialize;
impl Serialize for FailingSerialize {
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
Err(serde::ser::Error::custom(
"intentional dashboard serialization failure",
))
}
}
#[test]
fn dashboard_json_serialization_fails_closed() {
let result = serialize_dashboard_json("tasks", &FailingSerialize);
assert_eq!(result, Err(StatusCode::INTERNAL_SERVER_ERROR));
}
#[test]
fn dashboard_json_serialization_is_safe_for_inline_script_context() {
let payload = serde_json::json!({
"message": "</script><script>alert(1)</script>",
"agent": "alice & bob",
});
let json = serialize_dashboard_json("activity_log", &payload).unwrap();
assert!(
!json.contains("</script>"),
"inline dashboard JSON must not contain a script-closing sequence"
);
assert!(json.contains("\\u003c/script\\u003e"));
assert!(json.contains("\\u0026"));
}
#[test]
fn dashboard_renderers_do_not_inject_dynamic_data_through_inner_html() {
let source = include_str!("exoforge.rs");
let dashboard = source
.split("// ─── Dashboard HTML")
.nth(1)
.expect("dashboard HTML marker present")
.split("// ─── Tests")
.next()
.expect("tests marker present");
for forbidden in [
"row.innerHTML =",
"col.innerHTML =",
"container.innerHTML =",
"${{t.title}}",
"${{t.description}}",
"${{t.agent",
"${{t.spec_section}}",
"${{e.message}}",
] {
assert!(
!dashboard.contains(forbidden),
"ExoForge dashboard must render dynamic task/log fields with textContent, found {forbidden}"
);
}
}
#[test]
fn production_source_has_no_float_wall_clock_or_hashset_escape_hatches() {
let source = include_str!("exoforge.rs");
let production = source
.split("// ─── Tests")
.next()
.expect("tests marker present");
assert!(!production.contains("float_arithmetic"));
assert!(!production.contains("f64"));
assert!(!production.contains("SystemTime::now"));
assert!(!production.contains("unwrap_or_default()"));
let hash_set = "Hash".to_owned() + "Set";
assert!(!source.contains(&hash_set));
}
#[test]
fn async_handlers_do_not_lock_forge_state_directly() {
let source = include_str!("exoforge.rs");
let handlers = source
.split("// ─── API Handlers")
.nth(1)
.expect("API handler marker present")
.split("// ─── Router")
.next()
.expect("router marker present");
assert!(
!handlers.contains("state.lock()"),
"async ExoForge handlers must use blocking adapter helpers instead of directly locking std::sync::Mutex"
);
}
}