use crate::read_model::{
decision_overlay_for_session, feed_oldest_first, permalink, verify_chain, DecisionView,
FeedEvent,
};
use crate::{CliError, CliResult};
use clap::Args;
use rusqlite::Connection;
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Args)]
pub struct DbServeArgs {
#[arg(long, default_value = "127.0.0.1")]
pub host: String,
#[arg(long, default_value_t = 8787)]
pub port: u16,
#[arg(long, help = "serve one request, then exit")]
pub once: bool,
#[arg(
long,
help = "import outputs/ artifacts before each render (opt-in; OFF by default — does not change the ADR 025 DB-read-only default)"
)]
pub auto_import: bool,
#[arg(
long,
default_value = "outputs",
help = "runtime outputs root: used for --auto-import imports and, when --allow-writes is set, as the artifact root the browser composer send writes under (ADR 031 D3)"
)]
pub root: PathBuf,
#[arg(
long,
help = "enable browser-originated writes (ADR 031); OFF by default — the dashboard is read-only unless set."
)]
pub allow_writes: bool,
#[arg(
long,
default_value = "herdr",
help = "herdr executable the composer send shells out to (ADR 031)."
)]
pub herdr_bin: String,
}
struct WriteConfig {
token: String,
authority: String,
herdr_bin: String,
}
struct ServeContext {
db_path: PathBuf,
root: PathBuf,
auto_import: bool,
authority: String,
writes: Option<WriteConfig>,
active_sse: Arc<std::sync::atomic::AtomicUsize>,
}
struct DashboardSession {
session_id: String,
title: String,
phase: String,
mode: String,
workflow_status: String,
lead_agent_id: String,
artifact_ref: String,
updated_at: String,
next_action: String,
blockers: String,
asks_for_zevs: String,
risk_or_residual_uncertainty: String,
expected_wait: String,
}
pub fn serve(path: &Path, args: DbServeArgs) -> CliResult<()> {
if args.host != "127.0.0.1" {
return Err(CliError::usage(
"db dashboard server binds only to 127.0.0.1 in v0.2",
));
}
crate::db::open_database(path)?;
let listener = TcpListener::bind((args.host.as_str(), args.port)).map_err(|error| {
CliError::failure(format!(
"failed to bind dashboard server on {}:{}: {error}",
args.host, args.port
))
})?;
let address = listener.local_addr().map_err(|error| {
CliError::failure(format!(
"failed to read dashboard listener address: {error}"
))
})?;
println!("listening on http://{address}/");
std::io::stdout()
.flush()
.map_err(|error| CliError::failure(format!("failed to flush dashboard URL: {error}")))?;
let authority = address.to_string();
let writes = args.allow_writes.then(|| WriteConfig {
token: crate::dashboard_write::mint_csrf_token(),
authority: authority.clone(),
herdr_bin: args.herdr_bin.clone(),
});
let context = Arc::new(ServeContext {
db_path: path.to_path_buf(),
root: args.root.clone(),
auto_import: args.auto_import,
authority,
writes,
active_sse: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
});
if args.once {
let (stream, _) = listener.accept().map_err(|error| {
CliError::failure(format!("failed to accept dashboard request: {error}"))
})?;
return handle_connection(stream, &context);
}
for stream in listener.incoming() {
let stream = stream.map_err(|error| {
CliError::failure(format!("failed to accept dashboard request: {error}"))
})?;
let context = Arc::clone(&context);
std::thread::spawn(move || {
if let Err(error) = handle_connection(stream, &context) {
eprintln!("dashboard connection error: {}", error.message);
}
});
}
Ok(())
}
fn handle_connection(mut stream: TcpStream, ctx: &ServeContext) -> CliResult<()> {
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.map_err(|error| CliError::failure(format!("failed to set read timeout: {error}")))?;
let (head, raw_body) = read_http_request(&mut stream)?;
let request = crate::dashboard_write::parse_request(&head, raw_body);
if request.host_count != 1 || request.header("host") != Some(ctx.authority.as_str()) {
return write_response(
&mut stream,
"403 Forbidden",
"text/plain; charset=utf-8",
"",
"host mismatch\n",
);
}
if request.method == "GET" && request.route == "/events" {
return serve_sse(
&mut stream,
ctx,
query_param(&request.query, "session").as_deref(),
);
}
let (status, content_type, body, location, extra_headers) = if request.method == "POST"
&& request.route == "/send"
{
match ctx.writes.as_ref() {
Some(config) => match crate::dashboard_write::authorize_write(
&request,
&config.authority,
&config.token,
) {
Ok(()) => {
match crate::dashboard_write::handle_send(
&request,
ctx.db_path.as_path(),
ctx.root.as_path(),
&config.herdr_bin,
) {
crate::dashboard_write::WriteOutcome::Redirect(loc) => (
"303 See Other",
"text/plain; charset=utf-8",
String::new(),
Some(loc),
"",
),
crate::dashboard_write::WriteOutcome::RevealPlaintext(_) => (
"500 Internal Server Error",
"text/plain; charset=utf-8",
"unexpected reveal from send\n".to_string(),
None,
"",
),
crate::dashboard_write::WriteOutcome::Error { status, message } => (
status,
"text/html; charset=utf-8",
format!(
"<!doctype html><meta charset=\"utf-8\"><pre>{}</pre>",
escape_html(&message)
),
None,
"",
),
}
}
Err(error) => (
"403 Forbidden",
"text/plain; charset=utf-8",
format!("{}\n", error.message),
None,
"",
),
},
None => (
"405 Method Not Allowed",
"text/plain; charset=utf-8",
"writes disabled\n".to_string(),
None,
"",
),
}
} else if request.method == "POST" && request.route.starts_with("/decide/") {
let decision_type = request
.route
.strip_prefix("/decide/")
.unwrap_or("")
.to_string();
match ctx.writes.as_ref() {
Some(config) => match crate::dashboard_write::authorize_write(
&request,
&config.authority,
&config.token,
) {
Ok(()) => {
match crate::dashboard_write::handle_decide(
&request,
&decision_type,
ctx.db_path.as_path(),
ctx.root.as_path(),
&config.herdr_bin,
) {
crate::dashboard_write::WriteOutcome::Redirect(loc) => (
"303 See Other",
"text/plain; charset=utf-8",
String::new(),
Some(loc),
"",
),
crate::dashboard_write::WriteOutcome::RevealPlaintext(_) => (
"500 Internal Server Error",
"text/plain; charset=utf-8",
"unexpected reveal from decide\n".to_string(),
None,
"",
),
crate::dashboard_write::WriteOutcome::Error { status, message } => (
status,
"text/html; charset=utf-8",
format!(
"<!doctype html><meta charset=\"utf-8\"><pre>{}</pre>",
escape_html(&message)
),
None,
"",
),
}
}
Err(error) => (
"403 Forbidden",
"text/plain; charset=utf-8",
format!("{}\n", error.message),
None,
"",
),
},
None => (
"405 Method Not Allowed",
"text/plain; charset=utf-8",
"writes disabled\n".to_string(),
None,
"",
),
}
} else if request.method == "POST" && request.route == "/reveal" {
match ctx.writes.as_ref() {
Some(config) => match crate::dashboard_write::authorize_write(
&request,
&config.authority,
&config.token,
) {
Ok(()) => {
match crate::dashboard_write::handle_reveal(
&request,
ctx.db_path.as_path(),
ctx.root.as_path(),
) {
crate::dashboard_write::WriteOutcome::RevealPlaintext(text) => (
"200 OK",
"text/html; charset=utf-8",
format!(
"<!doctype html><meta charset=\"utf-8\"><pre>{}</pre>",
escape_html(&text)
),
None,
"Cache-Control: no-store\r\nPragma: no-cache\r\nX-Content-Type-Options: nosniff\r\n",
),
crate::dashboard_write::WriteOutcome::Redirect(_) => (
"500 Internal Server Error",
"text/plain; charset=utf-8",
"reveal must not redirect\n".to_string(),
None,
"",
),
crate::dashboard_write::WriteOutcome::Error { status, message } => (
status,
"text/html; charset=utf-8",
format!(
"<!doctype html><meta charset=\"utf-8\"><pre>{}</pre>",
escape_html(&message)
),
None,
"",
),
}
}
Err(error) => (
"403 Forbidden",
"text/plain; charset=utf-8",
format!("{}\n", error.message),
None,
"",
),
},
None => (
"405 Method Not Allowed",
"text/plain; charset=utf-8",
"writes disabled\n".to_string(),
None,
"",
),
}
} else if !matches!(request.method.as_str(), "GET" | "HEAD") {
(
"405 Method Not Allowed",
"text/plain; charset=utf-8",
"method not allowed\n".to_string(),
None,
"",
)
} else if matches!(request.route.as_str(), "/" | "/index.html") {
if ctx.auto_import {
crate::db::import_outputs_root(ctx.db_path.as_path(), ctx.root.as_path())?;
}
let connection = crate::db::open_read_database(ctx.db_path.as_path())?;
(
"200 OK",
"text/html; charset=utf-8",
render_dashboard(
&connection,
query_param(&request.query, "session").as_deref(),
ctx.writes.as_ref().map(|config| config.token.as_str()),
)?,
None,
"",
)
} else if matches!(request.route.as_str(), "/audit") {
if ctx.auto_import {
crate::db::import_outputs_root(ctx.db_path.as_path(), ctx.root.as_path())?;
}
let connection = crate::db::open_read_database(ctx.db_path.as_path())?;
(
"200 OK",
"text/html; charset=utf-8",
render_audit(
&connection,
query_param(&request.query, "session").as_deref(),
)?,
None,
"",
)
} else {
(
"404 Not Found",
"text/plain; charset=utf-8",
"not found\n".to_string(),
None,
"",
)
};
match location {
Some(target) => redirect_response(&mut stream, &target),
None => write_response(&mut stream, status, content_type, extra_headers, &body),
}
}
const SSE_CONNECTION_CAP: usize = 8;
const FEED_WINDOW: usize = 200;
fn windowed<T: Clone>(events: &[T], n: usize) -> Vec<T> {
if events.len() > n {
events[events.len() - n..].to_vec()
} else {
events.to_vec()
}
}
fn serve_sse(stream: &mut TcpStream, ctx: &ServeContext, session: Option<&str>) -> CliResult<()> {
struct SseGuard(std::sync::Arc<std::sync::atomic::AtomicUsize>);
impl Drop for SseGuard {
fn drop(&mut self) {
self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
}
}
let active = ctx
.active_sse
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
+ 1;
let _guard = SseGuard(ctx.active_sse.clone());
if active > SSE_CONNECTION_CAP {
return write_response(
stream,
"503 Service Unavailable",
"text/plain; charset=utf-8",
"",
"too many live connections\n",
);
}
let headers = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n";
stream
.write_all(headers.as_bytes())
.map_err(|e| CliError::failure(format!("failed to write SSE headers: {e}")))?;
use std::time::Duration;
let writes_token = ctx.writes.as_ref().map(|config| config.token.as_str());
let mut last_keys: Vec<String> = Vec::new();
let mut last_usage_key: Option<String> = None;
let mut first = true;
loop {
let (keys, reset_html, append_html_from, participants, session_state_html, usage) = {
let connection = crate::db::open_read_database(&ctx.db_path)?;
let sessions = load_sessions(&connection)?;
let selected = session
.and_then(|id| sessions.iter().find(|s| s.session_id == id))
.or_else(|| sessions.first());
let selected_id = selected.map(|s| s.session_id.clone());
let feed = match &selected_id {
Some(id) => windowed(
&crate::read_model::feed_oldest_first(&connection, id)?,
FEED_WINDOW,
),
None => Vec::new(),
};
let decision_overlay = match &selected_id {
Some(id) => crate::read_model::decision_overlay_for_session(&connection, id)?,
None => BTreeMap::new(),
};
let participants = match &selected_id {
Some(id) => roster_db_participants(&connection, id)?,
None => Vec::new(),
};
let session_state_html = format!(
"{}\u{1e}{}\u{1e}{}\u{1e}{}\u{1e}{}",
render_session_nav_inner(&sessions, selected_id.as_deref()).replace('\u{1e}', ""),
render_timeline_header_inner(selected).replace('\u{1e}', ""),
render_detail_inner(selected).replace('\u{1e}', ""),
render_chain_summary_inner(&connection, selected_id.as_deref())?
.replace('\u{1e}', ""),
render_mode_rail_inner(selected).replace('\u{1e}', ""),
);
let usage = match &selected_id {
Some(id) => {
let agg = crate::read_model::usage_aggregate(&connection, id)?;
let usage_key = crate::dashboard_live::usage_diff_key(&agg);
let usage_html = crate::dashboard_live::render_usage_html(&agg);
Some((usage_key, usage_html))
}
None => None,
};
let keys: Vec<String> = feed
.iter()
.map(|event| crate::dashboard_live::feed_diff_key(event, &decision_overlay))
.collect();
let delta = if first {
crate::dashboard_live::FeedDelta::Reset
} else {
crate::dashboard_live::diff_feed(&last_keys, &keys)
};
match delta {
crate::dashboard_live::FeedDelta::Reset => (
keys,
Some(render_feed_html(&feed, &decision_overlay, writes_token)),
None,
participants,
session_state_html,
usage,
),
crate::dashboard_live::FeedDelta::Append(from) if from < feed.len() => (
keys,
None,
Some(render_feed_html(
&feed[from..],
&decision_overlay,
writes_token,
)),
participants,
session_state_html,
usage,
),
crate::dashboard_live::FeedDelta::Append(_) => {
(keys, None, None, participants, session_state_html, usage)
} }
};
let mut buf = Vec::new();
if let Some(html) = reset_html {
crate::dashboard_live::sse_event(&mut buf, "feed-reset", &html);
} else if let Some(html) = append_html_from {
crate::dashboard_live::sse_event(&mut buf, "feed-append", &html);
} else {
crate::dashboard_live::sse_event(&mut buf, "heartbeat", "");
}
crate::dashboard_live::sse_event(&mut buf, "session-state", &session_state_html);
let herdr_bin = ctx
.writes
.as_ref()
.map(|w| w.herdr_bin.as_str())
.unwrap_or("herdr");
let roster = crate::dashboard_live::load_roster(herdr_bin, participants);
crate::dashboard_live::sse_event(
&mut buf,
"roster",
&crate::dashboard_live::render_roster_html(&roster),
);
if let Some((usage_key, usage_html)) = usage {
if Some(&usage_key) != last_usage_key.as_ref() {
crate::dashboard_live::sse_event(&mut buf, "usage", &usage_html);
last_usage_key = Some(usage_key);
}
}
if stream.write_all(&buf).is_err() {
return Ok(()); }
last_keys = keys;
first = false;
std::thread::sleep(Duration::from_millis(750));
}
}
fn redirect_response(stream: &mut TcpStream, location: &str) -> CliResult<()> {
let response = format!(
"HTTP/1.1 303 See Other\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
stream
.write_all(response.as_bytes())
.map_err(|error| CliError::failure(format!("failed to write redirect: {error}")))
}
fn query_param(query: &str, name: &str) -> Option<String> {
query.split('&').find_map(|part| {
let (key, value) = part.split_once('=')?;
(key == name).then(|| percent_decode(value))
})
}
fn read_http_request(stream: &mut TcpStream) -> CliResult<(String, Vec<u8>)> {
let mut buf = Vec::new();
let mut chunk = [0_u8; 1024];
let header_end = loop {
if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break pos + 4;
}
if buf.len() > 16_384 {
break buf.len();
}
let count = stream.read(&mut chunk).map_err(|error| {
CliError::failure(format!("failed to read dashboard request: {error}"))
})?;
if count == 0 {
break buf.len();
}
buf.extend_from_slice(&chunk[..count]);
};
let head = String::from_utf8_lossy(&buf[..header_end.min(buf.len())]).to_string();
let mut body = buf.get(header_end..).unwrap_or(&[]).to_vec();
let content_length = head
.split("\r\n")
.filter_map(|line| line.split_once(':'))
.find(|(key, _)| key.trim().eq_ignore_ascii_case("content-length"))
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
.unwrap_or(0)
.min(1_048_576);
while body.len() < content_length {
let count = stream.read(&mut chunk).map_err(|error| {
CliError::failure(format!("failed to read dashboard body: {error}"))
})?;
if count == 0 {
break;
}
body.extend_from_slice(&chunk[..count]);
}
body.truncate(content_length);
Ok((head, body))
}
fn write_response(
stream: &mut TcpStream,
status: &str,
content_type: &str,
extra_headers: &str,
body: &str,
) -> CliResult<()> {
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n{extra_headers}\r\n{body}",
body.len()
);
stream
.write_all(response.as_bytes())
.map_err(|error| CliError::failure(format!("failed to write dashboard response: {error}")))
}
pub(crate) fn known_targets(connection: &Connection, session_id: &str) -> CliResult<Vec<String>> {
let mut statement = connection
.prepare(
"SELECT DISTINCT agent_id || ':' || address AS target FROM (
SELECT target_agent_id AS agent_id, target_address AS address
FROM audit_records WHERE session_id = ?1
UNION
SELECT source_agent_id AS agent_id, source_address AS address
FROM audit_records WHERE session_id = ?1
)
WHERE agent_id IS NOT NULL AND address IS NOT NULL
ORDER BY target",
)
.map_err(|error| CliError::failure(format!("failed to prepare known_targets: {error}")))?;
let rows = statement
.query_map([session_id], |row| row.get::<_, String>(0))
.map_err(|error| CliError::failure(format!("failed to query known_targets: {error}")))?;
let mut targets = Vec::new();
for row in rows {
targets.push(
row.map_err(|error| CliError::failure(format!("failed to read target: {error}")))?,
);
}
Ok(targets)
}
pub(crate) fn sendable_targets(
connection: &Connection,
session_id: &str,
) -> CliResult<Vec<String>> {
let mut statement = connection
.prepare(
"SELECT DISTINCT agent_id || ':' || address AS target FROM (
SELECT target_agent_id AS agent_id, target_address AS address
FROM audit_records WHERE session_id = ?1 AND transport != 'none'
UNION
SELECT source_agent_id AS agent_id, source_address AS address
FROM audit_records WHERE session_id = ?1 AND transport != 'none'
)
WHERE agent_id IS NOT NULL AND address IS NOT NULL
AND agent_id != 'none' AND address NOT IN ('none', 'cli', 'dashboard')
ORDER BY target",
)
.map_err(|error| {
CliError::failure(format!("failed to prepare sendable_targets: {error}"))
})?;
let rows = statement
.query_map([session_id], |row| row.get::<_, String>(0))
.map_err(|error| CliError::failure(format!("failed to query sendable_targets: {error}")))?;
let mut targets = Vec::new();
for row in rows {
targets.push(row.map_err(|error| {
CliError::failure(format!("failed to read sendable target: {error}"))
})?);
}
Ok(targets)
}
pub(crate) fn known_targets_pairs(
connection: &Connection,
session_id: &str,
) -> CliResult<Vec<(String, String)>> {
Ok(known_targets(connection, session_id)?
.into_iter()
.filter_map(|t| {
t.split_once(':')
.map(|(a, b)| (a.to_string(), b.to_string()))
})
.collect())
}
pub(crate) fn roster_db_participants(
connection: &Connection,
session_id: &str,
) -> CliResult<Vec<crate::dashboard_live::RosterParticipant>> {
let mut rows: Vec<(String, String, String)> = Vec::new();
let mut lead_stmt = connection
.prepare(
"SELECT s.lead_agent_id,
COALESCE(a.current_address, ''),
COALESCE(a.current_agent_status, 'unknown')
FROM sessions AS s
LEFT JOIN agents AS a ON a.agent_id = s.lead_agent_id
WHERE s.session_id = ?1
AND s.lead_agent_id IS NOT NULL
AND s.lead_agent_id <> 'unknown'",
)
.map_err(|e| CliError::failure(format!("failed to prepare roster lead query: {e}")))?;
let lead_rows = lead_stmt
.query_map([session_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.map_err(|e| CliError::failure(format!("failed to query roster lead: {e}")))?;
for row in lead_rows {
rows.push(row.map_err(|e| CliError::failure(format!("failed to read roster lead: {e}")))?);
}
for (agent, address) in known_targets_pairs(connection, session_id)? {
let db_status: String = connection
.query_row(
"SELECT COALESCE(current_agent_status, 'unknown') FROM agents WHERE agent_id = ?1",
[agent.as_str()],
|row| row.get(0),
)
.unwrap_or_else(|_| "unknown".to_string());
rows.push((agent, address, db_status));
}
let mut sa_stmt = connection
.prepare(
"SELECT sa.agent_id,
COALESCE(a.current_address, ''),
sa.agent_status
FROM session_agents AS sa
LEFT JOIN agents AS a ON a.agent_id = sa.agent_id
WHERE sa.session_id = ?1",
)
.map_err(|e| {
CliError::failure(format!(
"failed to prepare roster session_agents query: {e}"
))
})?;
let sa_rows = sa_stmt
.query_map([session_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.map_err(|e| CliError::failure(format!("failed to query roster session_agents: {e}")))?;
for row in sa_rows {
rows.push(row.map_err(|e| {
CliError::failure(format!("failed to read roster session_agents: {e}"))
})?);
}
let mut deduped: Vec<(String, String, String)> = Vec::new();
for (agent, address, db_status) in rows {
if let Some(existing) = deduped.iter_mut().find(|(a, _, _)| *a == agent) {
if existing.1.is_empty() && !address.is_empty() {
existing.1 = address;
existing.2 = db_status;
}
} else {
deduped.push((agent, address, db_status));
}
}
deduped.sort_by(|left, right| left.0.cmp(&right.0));
let mut overlays = crate::read_model::participant_overlays_for_session(connection, session_id)?;
let mut participants: Vec<crate::dashboard_live::RosterParticipant> = deduped
.into_iter()
.map(|(agent, address, db_status)| {
let overlay = overlays.remove(&agent);
let (actor_kind, role_label, traits) = match overlay {
Some(view) => (view.actor_kind, view.role_label, view.traits),
None => (None, None, Vec::new()),
};
crate::dashboard_live::RosterParticipant {
agent,
address,
db_status,
actor_kind,
role_label,
traits,
}
})
.collect();
let mut leftover: Vec<crate::dashboard_live::RosterParticipant> = overlays
.into_values()
.map(|view| crate::dashboard_live::RosterParticipant {
agent: view.subject_actor_id,
address: "n/a".to_string(),
db_status: "unknown".to_string(),
actor_kind: view.actor_kind,
role_label: view.role_label,
traits: view.traits,
})
.collect();
leftover.sort_by(|left, right| left.agent.cmp(&right.agent));
participants.extend(leftover);
Ok(participants)
}
pub(crate) fn session_exists(connection: &Connection, session_id: &str) -> CliResult<bool> {
let count: i64 = connection
.query_row(
"SELECT COUNT(*) FROM sessions WHERE session_id = ?1",
[session_id],
|row| row.get(0),
)
.map_err(|error| CliError::failure(format!("failed to check session: {error}")))?;
Ok(count > 0)
}
fn render_dashboard(
connection: &Connection,
selected_session_id: Option<&str>,
csrf_token: Option<&str>,
) -> CliResult<String> {
let sessions = load_sessions(connection)?;
let selected = selected_session_id
.and_then(|session_id| {
sessions
.iter()
.find(|session| session.session_id == session_id)
})
.or_else(|| sessions.first());
let selected_id = selected.map(|session| session.session_id.as_str());
let feed = match selected_id {
Some(id) => windowed(&feed_oldest_first(connection, id)?, FEED_WINDOW),
None => Vec::new(),
};
let decision_overlay = match selected_id {
Some(id) => decision_overlay_for_session(connection, id)?,
None => BTreeMap::new(),
};
let mut html = String::new();
html.push_str("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">");
html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
html.push_str("<title>zynk dashboard</title><style>");
html.push_str(STYLES);
html.push_str("</style></head><body>");
html.push_str("<header class=\"topbar\">");
if let Some(session) = selected {
html.push_str(&format!(
"<span class=\"topbar-session\">{}</span><span class=\"topbar-mode\">{}</span>",
escape_html(&session.session_id),
escape_html(&session.mode),
));
}
html.push_str("<div class=\"usage-mount\" data-usage>");
if let Some(id) = selected_id {
html.push_str(&crate::dashboard_live::render_usage_html(
&crate::read_model::usage_aggregate(connection, id)?,
));
}
html.push_str("</div>");
html.push_str(
"<button id=\"view-toggle\" data-view-toggle type=\"button\" aria-controls=\"audit-view\" aria-expanded=\"false\">Audit</button>",
);
html.push_str("</header>");
html.push_str("<div class=\"app-shell\">");
html.push_str(
"<aside class=\"sidebar\"><div class=\"brand\">zynk</div><nav id=\"session-nav\">",
);
html.push_str(&render_session_nav_inner(&sessions, selected_id));
html.push_str("</nav>");
html.push_str("<nav class=\"mode-rail\" id=\"mode-rail\">");
html.push_str(&render_mode_rail_inner(selected));
html.push_str("</nav>");
html.push_str("<section id=\"roster\" class=\"roster-panel\"><h2>Participants</h2><div class=\"roster-mount\"></div></section>");
html.push_str("</aside>");
html.push_str(
"<main class=\"timeline\"><header id=\"timeline-header\" class=\"timeline-header\">",
);
html.push_str(&render_timeline_header_inner(selected));
html.push_str("</header>");
if let (Some(token), Some(session)) = (csrf_token, selected) {
let targets = sendable_targets(connection, &session.session_id)?;
let mut options = String::new();
if targets.len() > 1 {
options.push_str(&format!(
"<option value=\"__all__\">All sendable targets ({})</option>",
targets.len()
));
}
for target in &targets {
options.push_str(&format!(
"<option value=\"{}\">{}</option>",
escape_html(target),
escape_html(target),
));
}
let sendable_json = {
let mut json = String::from("[");
for (index, target) in targets.iter().enumerate() {
if index > 0 {
json.push(',');
}
json.push('"');
json.push_str(&target.replace('\\', "\\\\").replace('"', "\\\""));
json.push('"');
}
json.push(']');
json
};
html.push_str(&format!(
"<form class=\"composer\" method=\"post\" action=\"/send\" data-sendable-targets=\"{}\">\
<input type=\"hidden\" name=\"csrf\" value=\"{}\">\
<input type=\"hidden\" name=\"session\" value=\"{}\">\
<select name=\"to\" required>{}</select>\
<input name=\"type\" value=\"status-update\">\
<input name=\"body\" placeholder=\"message\u{2026}\" required>\
<button>Send</button></form>\
<div class=\"composer-status\" hidden></div>",
escape_html(&sendable_json),
escape_html(token),
escape_html(&session.session_id),
options,
));
if targets.is_empty() {
html.push_str("<p class=\"composer-note\">No known targets for this session yet.</p>");
}
html.push_str(
"<script>document.querySelector('.composer').addEventListener('submit',function(e){\
e.preventDefault();var f=e.target;var token=f.csrf.value;var session=f.session.value;\
var to=f.to.value;var type=f.type.value;var body=f.body.value;\
function doSend(target,mid){return fetch('/send',{method:'POST',\
headers:{'X-Zynk-CSRF':token,'Content-Type':'application/x-www-form-urlencoded'},\
body:new URLSearchParams({session:session,to:target,type:type,body:body,mid:mid})});}\
if(to!=='__all__'){doSend(to,'op-'+Date.now().toString(36)).then(function(r){\
if(r.redirected){location=r.url}else{r.text().then(function(t){document.body.innerHTML=t})}});return;}\
var targets=JSON.parse(f.getAttribute('data-sendable-targets')||'[]');\
var strip=f.parentNode.querySelector('.composer-status');if(strip){strip.hidden=false;strip.textContent='Sending to '+targets.length+' targets\u{2026}';}\
var stamp=Date.now().toString(36);\
Promise.all(targets.map(function(target,i){return doSend(target,'all-'+i+'-'+stamp)\
.then(function(r){return{target:target,ok:r.redirected||r.ok,status:r.status};})\
.catch(function(){return{target:target,ok:false,status:0};});}))\
.then(function(results){if(!strip)return;strip.innerHTML='';\
results.forEach(function(res){var row=document.createElement('div');\
row.className='composer-result '+(res.ok?'ok':'fail');\
row.textContent=res.target+': '+(res.ok?'sent':'failed ('+res.status+')');strip.appendChild(row);});});});</script>",
);
render_decision_controls(&mut html, token, &session.session_id, &targets);
html.push_str(
"<script>document.addEventListener('submit',function(e){var f=e.target;if(!f.classList||!f.classList.contains('decide-form'))return;e.preventDefault();fetch(f.getAttribute('action'),{method:'POST',headers:{'X-Zynk-CSRF':f.csrf.value,'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams(new FormData(f,e.submitter))}).then(function(r){if(r.redirected){location=r.url}else{r.text().then(function(t){document.body.innerHTML=t})}});});</script>",
);
html.push_str(
"<script>document.addEventListener('submit',function(e){var f=e.target;if(!f.classList||!f.classList.contains('reveal-form'))return;e.preventDefault();fetch(f.getAttribute('action'),{method:'POST',headers:{'X-Zynk-CSRF':f.csrf.value,'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams(new FormData(f))}).then(function(r){return r.text()}).then(function(t){document.body.innerHTML=t});});</script>",
);
}
html.push_str("<div id=\"feed\">");
if feed.is_empty() {
html.push_str("<section class=\"empty-state\">No feed entries yet.</section>");
} else {
for event in &feed {
render_feed_event(&mut html, event, &decision_overlay, csrf_token);
}
}
html.push_str("</div>");
html.push_str("<section id=\"audit-view\" class=\"audit-view\" hidden>");
html.push_str("<h2>Audit Trail</h2>");
html.push_str(&render_audit_inner(connection, selected_id)?);
html.push_str("</section>");
html.push_str(
"<script>(function(){var b=document.getElementById('view-toggle');\
var f=document.getElementById('feed');var a=document.getElementById('audit-view');\
if(!b||!f||!a)return;b.addEventListener('click',function(){\
var showAudit=a.hidden;a.hidden=!showAudit;f.classList.toggle('chat-hidden',showAudit);\
b.textContent=showAudit?'Chat':'Audit';b.setAttribute('aria-expanded',showAudit?'true':'false');});})();</script>",
);
html.push_str("</main>");
html.push_str("<aside id=\"detail\" class=\"context-panel\">");
html.push_str("<section class=\"panel-section status-section\"><div id=\"detail-status\">");
html.push_str(&render_detail_inner(selected));
html.push_str("</div></section>");
html.push_str("<section class=\"panel-section chain-section\"><div id=\"detail-chain\">");
html.push_str(&render_chain_summary_inner(connection, selected_id)?);
html.push_str("</div></section>");
html.push_str("<section class=\"panel-section artifacts-section\">");
html.push_str(&render_artifacts_inner(connection, selected_id)?);
html.push_str("</section>");
html.push_str("<section class=\"panel-section budget-section\"><h2>Context budget</h2>");
html.push_str("<div class=\"usage-mount\" data-usage>");
if let Some(id) = selected_id {
html.push_str(&crate::dashboard_live::render_usage_html(
&crate::read_model::usage_aggregate(connection, id)?,
));
}
html.push_str("</div>");
html.push_str("</section>");
html.push_str(
"<section class=\"panel-section controls-section\"><h2>Operator controls</h2><p class=\"controls-note\">Decide, reveal, and compose from the live feed and composer.</p></section>",
);
html.push_str("<section class=\"panel-section transport-panel\">");
html.push_str(&render_transport_inner());
html.push_str("</section>");
html.push_str("</aside></div>");
if let Some(session) = selected {
html.push_str(&format!(
"<div id=\"sse-cfg\" data-sid=\"{}\" hidden></div>",
escape_html(&session.session_id),
));
html.push_str(
"<script>(function(){var sid=document.getElementById('sse-cfg').dataset.sid;\
var es=new EventSource('/events?session='+encodeURIComponent(sid));\
var feed=document.getElementById('feed');var roster=document.querySelector('.roster-mount');\
es.addEventListener('feed-reset',function(e){if(feed)feed.innerHTML=e.data;});\
es.addEventListener('feed-append',function(e){if(feed)feed.insertAdjacentHTML('beforeend',e.data);});\
es.addEventListener('roster',function(e){if(roster)roster.innerHTML=e.data;});\
es.addEventListener('usage',function(e){document.querySelectorAll('[data-usage]').forEach(function(el){el.innerHTML=e.data;});});\
es.addEventListener('session-state',function(e){var p=e.data.split(String.fromCharCode(30));\
var nav=document.getElementById('session-nav');if(nav&&p[0]!==undefined)nav.innerHTML=p[0];\
var hdr=document.getElementById('timeline-header');if(hdr&&p[1]!==undefined)hdr.innerHTML=p[1];\
var det=document.getElementById('detail-status');if(det&&p[2]!==undefined)det.innerHTML=p[2];\
var chn=document.getElementById('detail-chain');if(chn&&p[3]!==undefined)chn.innerHTML=p[3];\
var mr=document.getElementById('mode-rail');if(mr&&p[4]!==undefined)mr.innerHTML=p[4];});\
})();</script>",
);
}
html.push_str("</body></html>");
Ok(html)
}
fn render_session_nav_inner(sessions: &[DashboardSession], _selected_id: Option<&str>) -> String {
let mut html = String::new();
if sessions.is_empty() {
html.push_str("<p class=\"empty\">No sessions in this database.</p>");
} else {
for session in sessions {
html.push_str(&format!(
"<a class=\"session-link\" href=\"/?session={}\"><span>{}</span><span class=\"badge status-{}\">{}</span></a>",
escape_url_component(&session.session_id),
escape_html(&session.session_id),
escape_class(&session.workflow_status),
escape_html(&session.workflow_status),
));
}
}
html
}
fn render_mode_rail_inner(selected: Option<&DashboardSession>) -> String {
let current = selected.map(|s| s.mode.as_str());
let mut html = String::new();
for mode in crate::decision::CANONICAL_MODES {
let class = if current == Some(mode) {
"mode active"
} else {
"mode"
};
html.push_str(&format!("<span class=\"{class}\">{mode}</span>"));
}
html
}
fn render_timeline_header_inner(selected: Option<&DashboardSession>) -> String {
let mut html = String::from("<div><h1>Timeline</h1>");
if let Some(session) = selected {
html.push_str(&format!(
"<p>{} / {} / {}</p>",
escape_html(&session.session_id),
escape_html(&session.phase),
escape_html(&session.mode)
));
}
html.push_str("</div>");
html
}
fn render_detail_inner(selected: Option<&DashboardSession>) -> String {
let mut html = String::from("<h2>Status</h2>");
if let Some(session) = selected {
html.push_str(&format!(
"<dl><dt>Session</dt><dd>{}</dd><dt>State</dt><dd>{}</dd><dt>Next</dt><dd>{}</dd><dt>Ask</dt><dd>{}</dd><dt>Blockers</dt><dd>{}</dd><dt>Risk</dt><dd>{}</dd><dt>Expected wait</dt><dd>{}</dd><dt>Artifact</dt><dd>{}</dd><dt>Lead</dt><dd>{}</dd><dt>Updated</dt><dd>{}</dd></dl>",
escape_html(&session.title),
escape_html(&session.workflow_status),
escape_html(&session.next_action),
escape_html(&session.asks_for_zevs),
escape_html(&session.blockers),
escape_html(&session.risk_or_residual_uncertainty),
escape_html(&session.expected_wait),
escape_html(&session.artifact_ref),
escape_html(&session.lead_agent_id),
escape_html(&session.updated_at),
));
}
html
}
fn render_chain_summary_inner(
connection: &Connection,
session_id: Option<&str>,
) -> CliResult<String> {
let mut html = String::from("<h2>Audit chain</h2>");
let Some(id) = session_id else {
html.push_str("<p class=\"chain-summary empty\">No session.</p>");
return Ok(html);
};
let verification = verify_chain(connection, id)?;
let badge = if verification.ok { "intact" } else { "anomaly" };
let latest: Option<String> = connection
.query_row(
"SELECT audit_id FROM audit_records WHERE session_id = ?1
ORDER BY timestamp DESC, audit_id DESC LIMIT 1",
[id],
|row| row.get(0),
)
.ok();
let detail = if verification.ok {
match latest {
Some(audit_id) => format!(
"chain {} \u{00b7} {} verified \u{00b7} latest {}",
badge, verification.verified_count, audit_id
),
None => format!(
"chain {} \u{00b7} {} verified",
badge, verification.verified_count
),
}
} else {
format!(
"chain {} at {}",
badge,
verification.broken_at.clone().unwrap_or_default()
)
};
html.push_str(&format!(
"<p class=\"chain-summary {}\">{}</p>",
badge,
escape_html(&detail)
));
Ok(html)
}
fn render_artifacts_inner(connection: &Connection, session_id: Option<&str>) -> CliResult<String> {
let mut html = String::from("<h2>Artifacts</h2>");
let Some(id) = session_id else {
html.push_str("<p class=\"empty-state\">No artifacts.</p>");
return Ok(html);
};
let mut statement = connection
.prepare(
"SELECT work_event_id, payload FROM work_events
WHERE session_id = ?1 AND kind = 'artifact' ORDER BY timestamp, work_event_id",
)
.map_err(|e| CliError::failure(format!("failed to prepare artifacts query: {e}")))?;
let rows = statement
.query_map([id], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})
.map_err(|e| CliError::failure(format!("failed to query artifacts: {e}")))?;
let mut paths: Vec<crate::work_event::ArtifactFile> = Vec::new();
for row in rows {
let (work_event_id, payload) =
row.map_err(|e| CliError::failure(format!("failed to read artifact: {e}")))?;
match crate::work_event::WorkEventPayload::from_storage(&payload)? {
crate::work_event::WorkEventPayload::Artifact { files } => paths.extend(files),
other => {
return Err(CliError::failure(format!(
"render_artifacts: work_event {work_event_id} kind='artifact' has non-Artifact payload (payload kind {:?})",
other.kind()
)));
}
}
}
if paths.is_empty() {
html.push_str("<p class=\"empty-state\">No artifacts.</p>");
} else {
html.push_str("<ul class=\"artifact-list\">");
for file in &paths {
html.push_str(&format!(
"<li><span class=\"path\">{}</span> <i class=\"d-add\">+{}</i><i class=\"d-rem\">\u{2212}{}</i></li>",
escape_html(&file.path),
file.add,
file.rem,
));
}
html.push_str("</ul>");
}
Ok(html)
}
fn render_transport_inner() -> String {
let provenance = if std::env::var("HERDR_ENV").as_deref() == Ok("1") {
"live-herdr"
} else {
"db-fallback"
};
format!(
"<h2>Transport</h2><dl class=\"transport\"><dt>Connection</dt><dd>loopback (127.0.0.1, read-only)</dd><dt>Roster source</dt><dd>{provenance}</dd></dl>"
)
}
fn render_feed_html(
events: &[FeedEvent],
overlay: &BTreeMap<i64, DecisionView>,
writes: Option<&str>,
) -> String {
let mut html = String::new();
for event in events {
render_feed_event(&mut html, event, overlay, writes);
}
html
}
fn render_feed_event(
html: &mut String,
event: &FeedEvent,
overlay: &BTreeMap<i64, DecisionView>,
writes: Option<&str>,
) {
if event.source_table == "operator_decisions" {
if let Some(decision) = &event.decision {
render_decision_card(html, event, decision, writes);
return;
}
}
if let Some(work) = &event.work {
use crate::work_event::WorkEventPayload::*;
html.push_str(&format!(
"<article class=\"feed-item kind-{}\"><div class=\"timestamp\">{}</div>",
escape_class(&event.kind),
escape_html(&event.timestamp)
));
html.push_str(&format!(
"<h2>{} <span class=\"kind\">{}</span></h2>",
escape_html(event.actor_agent_id.as_deref().unwrap_or("system")),
escape_html(&event.kind)
));
match work {
Think { text } | System { text } => {
html.push_str(&format!("<p class=\"body\">{}</p>", escape_html(text)))
}
Tool {
name,
arg,
output,
ok,
} => html.push_str(&format!(
"<div class=\"tool\"><code>{}({})</code><pre>{}</pre><span class=\"ok-{}\">{}</span></div>",
escape_html(name),
escape_html(arg),
escape_html(output),
ok,
if *ok { "ok" } else { "fail" }
)),
Diff {
file,
added,
removed,
..
} => html.push_str(&format!(
"<div class=\"diff\"><span class=\"path\">{}</span><i class=\"d-add\">+{}</i><i class=\"d-rem\">\u{2212}{}</i></div>",
escape_html(file),
added,
removed
)),
Plan { title, checklist } => {
html.push_str(&format!("<div class=\"plan\"><b>{}</b><ul>", escape_html(title)));
for it in checklist {
html.push_str(&format!("<li>{}</li>", escape_html(it)));
}
html.push_str("</ul></div>");
}
Artifact { files } => {
html.push_str("<div class=\"artifact\"><ul>");
for f in files {
html.push_str(&format!(
"<li>{} <i class=\"d-add\">+{}</i><i class=\"d-rem\">\u{2212}{}</i></li>",
escape_html(&f.path),
f.add,
f.rem
));
}
html.push_str("</ul></div>");
}
Usage { agent, tokens, .. } => html.push_str(&format!(
"<div class=\"usage\">{} \u{00b7} <b>{}</b> tokens</div>",
escape_html(agent),
thousands(*tokens)
)),
Gate {
title,
summary,
actions,
..
} => {
html.push_str(&format!(
"<div class=\"gate\"><b>{}</b><p>{}</p><div class=\"actions\">",
escape_html(title),
escape_html(summary)
));
for a in actions {
html.push_str(&format!("<span class=\"act\">{}</span>", escape_html(a)));
}
html.push_str("</div>");
render_decision_overlay(html, event, overlay, writes);
render_gate_control(html, event, writes);
html.push_str("</div>");
}
Conflict {
topic, positions, ..
} => {
html.push_str(&format!("<div class=\"conflict\"><b>{}</b>", escape_html(topic)));
for p in positions {
html.push_str(&format!(
"<div class=\"pos\"><span class=\"who\">{}</span> {}</div>",
escape_html(&p.from),
escape_html(&p.stance)
));
}
render_decision_overlay(html, event, overlay, writes);
render_conflict_control(html, event, writes);
html.push_str("</div>");
}
}
html.push_str("</article>");
return;
}
html.push_str(&format!(
"<article class=\"feed-item kind-{}\"><div class=\"timestamp\">{}</div>",
escape_class(&event.kind),
escape_html(&event.timestamp),
));
let who = event.actor_agent_id.as_deref().unwrap_or("system");
let label = event.subtype.as_deref().unwrap_or(event.kind.as_str());
html.push_str(&format!(
"<h2>{} <span class=\"kind\">{}</span>",
escape_html(who),
escape_html(label),
));
if let Some(mid) = &event.mid {
html.push_str(&format!(" <span class=\"mid\">{}</span>", escape_html(mid)));
}
html.push_str("</h2>");
match &event.body {
Some(body) => html.push_str(&format!("<p class=\"body\">{}</p>", escape_html(body))),
None if event.kind == "message" => {
html.push_str("<p class=\"redacted\">\u{2298} redacted \u{00b7} hash-only</p>")
}
None => {
if let Some(summary) = &event.summary {
html.push_str(&format!("<p>{}</p>", escape_html(summary)));
}
}
}
if event.proof_audit_id.is_some() {
html.push_str("<div class=\"proof-strip\">");
html.push_str(&format!(
"<span class=\"proof proof-{}\">{} / {}</span>",
escape_class(event.delivery_status.as_deref().unwrap_or("unknown")),
escape_html(event.delivery_status.as_deref().unwrap_or("unknown")),
escape_html(event.verified_by.as_deref().unwrap_or("unknown")),
));
if let (Some(source), Some(target)) = (&event.source_address, &event.target_address) {
html.push_str(&format!(
"<span class=\"addr\">{} \u{2192} {} \u{00b7} {}</span>",
escape_html(source),
escape_html(target),
escape_html(event.transport.as_deref().unwrap_or("?")),
));
}
if let Some(link) = permalink(event) {
html.push_str(&format!(
"<span class=\"permalink\">{}</span>",
escape_html(&link)
));
}
if let Some(audit_id) = &event.proof_audit_id {
render_reveal_control(html, &event.session_id, audit_id, event.revealable, writes);
}
html.push_str("</div>");
}
html.push_str("</article>");
}
fn render_decision_overlay(
html: &mut String,
event: &FeedEvent,
overlay: &BTreeMap<i64, DecisionView>,
writes: Option<&str>,
) {
let Ok(work_event_id) = event.source_id.parse::<i64>() else {
return; };
let Some(decision) = overlay.get(&work_event_id) else {
return; };
let (marker, value) = match decision.verdict.as_deref() {
Some(verdict) => ("decision-verdict", verdict),
None => (
"decision-resolution",
decision.resolution.as_deref().unwrap_or(""),
),
};
let actor = decision.actor_agent_id.as_deref().unwrap_or("operator");
html.push_str(&format!(
"<div class=\"decision-overlay {}\"><span class=\"verdict\">{}</span>\
<span class=\"by\">{} \u{00b7} {}</span>",
escape_class(marker),
escape_html(value),
escape_html(actor),
escape_html(&decision.timestamp),
));
render_notify_badge(html, decision);
if let Some(note) = &decision.note {
html.push_str(&format!("<p class=\"note\">{}</p>", escape_html(note)));
}
render_reveal_control(
html,
&event.session_id,
&decision.audit_id,
decision.revealable,
writes,
);
html.push_str("</div>");
}
fn render_notify_badge(html: &mut String, decision: &DecisionView) {
if decision.notification_status == "not-requested" {
return;
}
html.push_str(&format!(
"<span class=\"notify notify-{}\">notify: {}</span>",
escape_class(&decision.notification_status),
escape_html(&decision.notification_status),
));
}
fn render_decision_card(
html: &mut String,
event: &FeedEvent,
decision: &DecisionView,
writes: Option<&str>,
) {
let marker = match decision.decision_type.as_str() {
"mode-switch" => "decision-mode",
"interrupt" => "decision-interrupt",
"redirect" => "decision-redirect",
_ => "decision-other",
};
html.push_str(&format!(
"<article class=\"feed-item decision {}\"><div class=\"timestamp\">{}</div>",
escape_class(marker),
escape_html(&event.timestamp),
));
html.push_str(&format!(
"<h2>{} <span class=\"kind\">{}</span></h2>",
escape_html(decision.actor_agent_id.as_deref().unwrap_or("operator")),
escape_html(&decision.decision_type),
));
html.push_str("<div class=\"decision-body\">");
match decision.decision_type.as_str() {
"mode-switch" => {
html.push_str(&format!(
"<span class=\"mode-to\">\u{2192} {}</span>",
escape_html(decision.mode_to.as_deref().unwrap_or("")),
));
}
"interrupt" => {
if let Some(reason) = &decision.reason {
html.push_str(&format!("<p class=\"reason\">{}</p>", escape_html(reason)));
}
}
"redirect" => {
html.push_str(&format!(
"<span class=\"target-agent\">\u{2192} {}</span>",
escape_html(decision.target_agent.as_deref().unwrap_or("")),
));
if let Some(reason) = &decision.reason {
html.push_str(&format!("<p class=\"reason\">{}</p>", escape_html(reason)));
}
}
_ => {}
}
render_notify_badge(html, decision);
if let Some(note) = &decision.note {
html.push_str(&format!("<p class=\"note\">{}</p>", escape_html(note)));
}
render_reveal_control(
html,
&event.session_id,
&decision.audit_id,
decision.revealable,
writes,
);
html.push_str("</div></article>");
}
fn render_reveal_control(
html: &mut String,
session_id: &str,
audit_id: &str,
revealable: bool,
writes: Option<&str>,
) {
let Some(token) = writes else {
return; };
if !revealable {
return; }
html.push_str(&format!(
"<form class=\"reveal-form\" method=\"post\" action=\"/reveal\">\
<input type=\"hidden\" name=\"csrf\" value=\"{}\">\
<input type=\"hidden\" name=\"session\" value=\"{}\">\
<input type=\"hidden\" name=\"audit_id\" value=\"{}\">\
<button type=\"submit\">Reveal</button>\
</form>",
escape_html(token),
escape_html(session_id),
escape_html(audit_id),
));
}
fn render_gate_control(html: &mut String, event: &FeedEvent, writes: Option<&str>) {
let Some(token) = writes else {
return; };
html.push_str(&format!(
"<form class=\"decide-form decide-gate\" method=\"post\" action=\"/decide/gate\">\
<input type=\"hidden\" name=\"csrf\" value=\"{}\">\
<input type=\"hidden\" name=\"session\" value=\"{}\">\
<input type=\"hidden\" name=\"ref\" value=\"{}\">\
<input name=\"note\" placeholder=\"note (optional)\u{2026}\">\
<button type=\"submit\" name=\"verdict\" value=\"approve\">Approve</button>\
<button type=\"submit\" name=\"verdict\" value=\"request-changes\">Request changes</button>\
</form>",
escape_html(token),
escape_html(&event.session_id),
escape_html(&event.source_id),
));
}
fn render_conflict_control(html: &mut String, event: &FeedEvent, writes: Option<&str>) {
let Some(token) = writes else {
return; };
html.push_str(&format!(
"<form class=\"decide-form decide-conflict\" method=\"post\" action=\"/decide/conflict\">\
<input type=\"hidden\" name=\"csrf\" value=\"{}\">\
<input type=\"hidden\" name=\"session\" value=\"{}\">\
<input type=\"hidden\" name=\"ref\" value=\"{}\">\
<input name=\"resolution\" placeholder=\"resolution\u{2026}\" required>\
<input name=\"note\" placeholder=\"note (optional)\u{2026}\">\
<button type=\"submit\">Resolve</button>\
</form>",
escape_html(token),
escape_html(&event.session_id),
escape_html(&event.source_id),
));
}
fn render_decision_controls(
html: &mut String,
token: &str,
session_id: &str,
known_targets: &[String],
) {
let notify_select = if known_targets.is_empty() {
String::new()
} else {
let mut options = String::from("<option value=\"\">no notify</option>");
for target in known_targets {
options.push_str(&format!(
"<option value=\"{}\">{}</option>",
escape_html(target),
escape_html(target),
));
}
format!("<select name=\"notify\">{options}</select>")
};
html.push_str("<section class=\"decide-controls\">");
let mut mode_options = String::new();
for mode in crate::decision::CANONICAL_MODES {
mode_options.push_str(&format!(
"<option value=\"{}\">{}</option>",
escape_html(mode),
escape_html(mode),
));
}
html.push_str(&format!(
"<form class=\"decide-form decide-mode\" method=\"post\" action=\"/decide/mode\">\
<input type=\"hidden\" name=\"csrf\" value=\"{token}\">\
<input type=\"hidden\" name=\"session\" value=\"{session}\">\
<label>mode <select name=\"to\" required>{modes}</select></label>\
<button type=\"submit\">Switch mode</button>\
</form>",
token = escape_html(token),
session = escape_html(session_id),
modes = mode_options,
));
html.push_str(&format!(
"<form class=\"decide-form decide-interrupt\" method=\"post\" action=\"/decide/interrupt\">\
<input type=\"hidden\" name=\"csrf\" value=\"{token}\">\
<input type=\"hidden\" name=\"session\" value=\"{session}\">\
<input name=\"reason\" placeholder=\"interrupt reason (optional)\u{2026}\">{notify}\
<button type=\"submit\">Interrupt</button>\
</form>",
token = escape_html(token),
session = escape_html(session_id),
notify = notify_select,
));
if !known_targets.is_empty() {
let mut redirect_options = String::new();
for target in known_targets {
redirect_options.push_str(&format!(
"<option value=\"{}\">{}</option>",
escape_html(target),
escape_html(target),
));
}
html.push_str(&format!(
"<form class=\"decide-form decide-redirect\" method=\"post\" action=\"/decide/redirect\">\
<input type=\"hidden\" name=\"csrf\" value=\"{token}\">\
<input type=\"hidden\" name=\"session\" value=\"{session}\">\
<label>redirect to <select name=\"to\" required>{targets}</select></label>\
<input name=\"reason\" placeholder=\"reason (optional)\u{2026}\">\
<button type=\"submit\">Redirect</button>\
</form>",
token = escape_html(token),
session = escape_html(session_id),
targets = redirect_options,
));
}
html.push_str("</section>");
}
fn render_audit_inner(
connection: &Connection,
selected_session_id: Option<&str>,
) -> CliResult<String> {
let sessions = load_sessions(connection)?;
let selected = selected_session_id
.and_then(|id| sessions.iter().find(|session| session.session_id == id))
.or_else(|| sessions.first());
let mut html = String::new();
if let Some(session) = selected {
let verification = verify_chain(connection, &session.session_id)?;
let label = if verification.ok {
format!(
"chain intact \u{00b7} {} verified",
verification.verified_count
)
} else {
format!(
"chain anomaly at {}",
verification.broken_at.unwrap_or_default()
)
};
html.push_str(&format!("<p class=\"verify\">{}</p>", escape_html(&label)));
let mut statement = connection
.prepare(
"SELECT audit_id, COALESCE(previous_audit_id, 'genesis'), record_type,
delivery_status, verified_by, payload_hash, timestamp
FROM audit_records WHERE session_id = ?1 ORDER BY timestamp, audit_id",
)
.map_err(|error| CliError::failure(format!("failed to query audit view: {error}")))?;
let rows = statement
.query_map([session.session_id.as_str()], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, String>(6)?,
))
})
.map_err(|error| CliError::failure(format!("failed to read audit view: {error}")))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| CliError::failure(format!("failed to read audit view: {error}")))?;
for (audit_id, previous, record_type, delivery, verified, hash, timestamp) in rows {
html.push_str(&format!(
"<article class=\"feed-item\"><div class=\"timestamp\">{}</div><h2>{} <span class=\"kind\">{}</span></h2><p>\u{2190} {} \u{00b7} {} / {} \u{00b7} {}</p></article>",
escape_html(×tamp),
escape_html(&audit_id),
escape_html(&record_type),
escape_html(&previous),
escape_html(&delivery),
escape_html(&verified),
escape_html(&hash),
));
}
} else {
html.push_str("<section class=\"empty-state\">No session.</section>");
}
Ok(html)
}
fn render_audit(connection: &Connection, selected_session_id: Option<&str>) -> CliResult<String> {
let mut html = String::new();
html.push_str("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">");
html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
html.push_str("<title>zynk audit</title><style>");
html.push_str(STYLES);
html.push_str("</style></head><body><div class=\"app-shell\"><main class=\"timeline\">");
html.push_str("<header class=\"timeline-header\"><div><h1>Audit Trail</h1></div></header>");
html.push_str(&render_audit_inner(connection, selected_session_id)?);
html.push_str("</main></div></body></html>");
Ok(html)
}
fn load_sessions(connection: &Connection) -> CliResult<Vec<DashboardSession>> {
let mut statement = connection
.prepare(
"SELECT
s.session_id,
s.title,
COALESCE(se.phase, s.phase),
COALESCE(se.mode, s.mode),
COALESCE(se.workflow_status, s.workflow_status),
COALESCE(s.lead_agent_id, 'unknown'),
COALESCE(s.artifact_ref, 'unknown'),
COALESCE(se.timestamp, s.updated_at),
COALESCE(se.next_action, 'unknown'),
COALESCE(se.blockers, 'unknown'),
COALESCE(se.asks_for_zevs, 'unknown'),
COALESCE(se.risk_or_residual_uncertainty, 'unknown'),
COALESCE(se.expected_wait, 'unknown')
FROM sessions AS s
LEFT JOIN status_events AS se
ON se.status_event_id = (
SELECT status_event_id
FROM status_events
WHERE session_id = s.session_id
ORDER BY timestamp DESC, status_event_id DESC
LIMIT 1
)
ORDER BY COALESCE(se.timestamp, s.updated_at) DESC, s.session_id",
)
.map_err(|error| {
CliError::failure(format!("failed to query dashboard sessions: {error}"))
})?;
let mut sessions = statement
.query_map([], |row| {
Ok(DashboardSession {
session_id: row.get(0)?,
title: row.get(1)?,
phase: row.get(2)?,
mode: row.get(3)?,
workflow_status: row.get(4)?,
lead_agent_id: row.get(5)?,
artifact_ref: row.get(6)?,
updated_at: row.get(7)?,
next_action: row.get(8)?,
blockers: row.get(9)?,
asks_for_zevs: row.get(10)?,
risk_or_residual_uncertainty: row.get(11)?,
expected_wait: row.get(12)?,
})
})
.map_err(|error| CliError::failure(format!("failed to read dashboard sessions: {error}")))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| {
CliError::failure(format!("failed to read dashboard sessions: {error}"))
})?;
for session in &mut sessions {
if let Some((decision_ts, mode_to)) =
crate::db::latest_mode_decision(connection, &session.session_id)?
{
if decision_ts.as_str() > session.updated_at.as_str() {
session.mode = mode_to;
}
}
}
Ok(sessions)
}
pub(crate) fn escape_html(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn thousands(n: u64) -> String {
let digits = n.to_string();
let bytes = digits.as_bytes();
let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
let len = bytes.len();
for (index, byte) in bytes.iter().enumerate() {
if index > 0 && (len - index).is_multiple_of(3) {
grouped.push(',');
}
grouped.push(*byte as char);
}
grouped
}
fn escape_class(value: &str) -> String {
escape_html(value)
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
ch
} else {
'-'
}
})
.collect()
}
pub(crate) fn escape_url_component(value: &str) -> String {
let mut escaped = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
escaped.push(byte as char);
} else {
escaped.push_str(&format!("%{byte:02X}"));
}
}
escaped
}
pub(crate) fn percent_decode(value: &str) -> String {
let mut decoded = Vec::new();
let bytes = value.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
if let Ok(hex) = std::str::from_utf8(&bytes[index + 1..index + 3]) {
if let Ok(byte) = u8::from_str_radix(hex, 16) {
decoded.push(byte);
index += 3;
continue;
}
}
}
decoded.push(bytes[index]);
index += 1;
}
String::from_utf8_lossy(&decoded).to_string()
}
const STYLES: &str = r#"
:root { color-scheme: light; --ink: #1c2024; --muted: #667085; --line: #d6dbe1; --panel: #f7f8fa; --accent: #0f766e; --warn: #9a3412; --ok: #166534; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: #ffffff; letter-spacing: 0; }
.topbar { display: flex; align-items: center; gap: 14px; padding: 10px clamp(14px, 2vw, 24px); border-bottom: 1px solid var(--line); background: var(--panel); }
.topbar-session { font-weight: 600; }
.topbar-mode { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); border: 1px solid var(--line); border-radius: 6px; padding: 2px 8px; background: #fff; }
.topbar .usage-mount { margin-left: auto; }
.topbar #view-toggle { margin: 0; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: minmax(220px, 18vw) minmax(0, 1fr) minmax(260px, 22vw); }
.sidebar, .context-panel { background: var(--panel); border-color: var(--line); padding: 18px; overflow: auto; }
.sidebar { border-right: 1px solid var(--line); }
.context-panel { border-left: 1px solid var(--line); }
.context-panel .panel-section { margin: 0 0 18px; padding: 0 0 14px; border-bottom: 1px solid var(--line); }
.context-panel .panel-section:last-child { border-bottom: 0; margin-bottom: 0; padding-bottom: 0; }
.context-panel h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); margin: 0 0 8px; }
.chain-summary.intact { color: var(--ok); }
.chain-summary.anomaly { color: var(--warn); }
.artifact-list { list-style: none; margin: 0; padding: 0; }
.artifact-list li { display: flex; gap: 6px; align-items: baseline; font-size: 12px; padding: 2px 0; }
.controls-note { color: var(--muted); font-size: 12px; margin: 0; }
.brand { font-weight: 700; font-size: 18px; margin-bottom: 18px; }
.session-link { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; color: inherit; text-decoration: none; padding: 9px 0; border-bottom: 1px solid var(--line); }
.badge, .proof { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 6px; background: #fff; font-size: 12px; white-space: nowrap; }
.status-working, .proof-observed { border-color: #86efac; color: var(--ok); }
.status-blocked, .status-waiting-for-operator, .proof-failed { border-color: #fdba74; color: var(--warn); }
.proof-sent { border-color: #5eead4; color: var(--accent); }
.status-idle, .status-done, .proof-drafted, .proof-unknown { border-color: #d0d5dd; color: var(--muted); }
.timeline { padding: 20px clamp(18px, 3vw, 42px); overflow: auto; }
.timeline-header { display: flex; justify-content: space-between; align-items: end; border-bottom: 1px solid var(--line); margin-bottom: 18px; padding-bottom: 12px; }
h1 { font-size: 24px; margin: 0; }
h2 { font-size: 15px; margin: 4px 0; }
p { color: var(--muted); margin: 4px 0; }
.timeline-item { max-width: 860px; border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; margin: 0 0 12px; background: #fff; }
.timestamp { color: var(--muted); font-size: 12px; }
.proof-strip { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
dl { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 9px 12px; margin: 0; }
dt { color: var(--muted); }
dd { margin: 0; overflow-wrap: anywhere; }
.empty, .empty-state { color: var(--muted); }
.chat-hidden { display: none; }
#view-toggle { display: inline-flex; align-items: center; min-height: 28px; padding: 4px 12px; margin: 0 0 12px; border: 1px solid var(--line); border-radius: 6px; background: #fff; color: var(--ink); font: inherit; cursor: pointer; }
.mode-rail { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--line); }
.mode-rail .mode { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 6px; background: #fff; font-size: 12px; color: var(--muted); }
.mode-rail .mode.active { color: var(--ink); border-color: var(--accent, var(--ink)); font-weight: 600; }
.roster-panel { margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--line); }
.roster { list-style: none; margin: 0; padding: 0; }
.roster li { display: flex; align-items: center; gap: 8px; padding: 5px 0; }
.feed-item { max-width: 860px; border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; margin: 0 0 12px; background: #fff; }
.feed-item .body { color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; margin: 6px 0; }
.feed-item .redacted { color: var(--muted); font-style: italic; }
.kind { color: var(--muted); font-weight: 400; font-size: 12px; }
.mid { color: var(--muted); font-size: 12px; }
.addr, .permalink { color: var(--muted); font-size: 12px; }
.verify { color: var(--ok); font-weight: 600; }
.tool code { font-size: 12px; color: var(--ink); }
.tool pre { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; margin: 6px 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tool .ok-true { color: var(--ok); font-weight: 600; }
.tool .ok-false { color: var(--warn); font-weight: 600; }
.diff { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 0; }
.diff .path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; overflow-wrap: anywhere; }
.d-add { color: var(--ok); font-style: normal; font-size: 12px; }
.d-rem { color: var(--warn); font-style: normal; font-size: 12px; }
.plan ul, .artifact ul { margin: 6px 0; padding-left: 18px; color: var(--ink); }
.plan li, .artifact li { margin: 2px 0; }
.usage { margin: 6px 0; color: var(--ink); }
.gate { margin: 6px 0; }
.gate .actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.gate .act { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 6px; background: #fff; font-size: 12px; }
.conflict { margin: 6px 0; }
.conflict .pos { margin: 4px 0; color: var(--ink); }
.conflict .who { color: var(--muted); font-weight: 600; }
.decision-overlay { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--line); }
.decision-overlay .verdict { font-weight: 600; padding: 2px 8px; border-radius: 6px; background: var(--panel); border: 1px solid var(--line); font-size: 12px; }
.decision-overlay.decision-verdict .verdict { color: var(--ok); }
.decision-overlay.decision-resolution .verdict { color: var(--accent, var(--ink)); }
.decision-overlay .by { color: var(--muted); font-size: 12px; }
.decision-overlay .note, .decision .note { color: var(--ink); font-size: 12px; margin: 4px 0 0; flex-basis: 100%; }
.feed-item.decision .decision-body { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 0; color: var(--ink); }
.feed-item.decision .mode-to, .feed-item.decision .target-agent { font-weight: 600; }
.feed-item.decision .reason { color: var(--muted); font-size: 12px; flex-basis: 100%; margin: 0; }
.notify { font-size: 11px; padding: 1px 6px; border-radius: 6px; border: 1px solid var(--line); }
.notify-sent { color: var(--ok); }
.notify-failed { color: var(--warn); }
@media (max-width: 900px) { .app-shell { grid-template-columns: 1fr; } .sidebar, .context-panel { border: 0; border-bottom: 1px solid var(--line); } }
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windowed_keeps_last_n_else_all() {
let five = [1, 2, 3, 4, 5];
assert_eq!(windowed(&five, 3), vec![3, 4, 5]);
let two = [1, 2];
assert_eq!(windowed(&two, 3), vec![1, 2]);
assert_eq!(windowed(&five, 5), vec![1, 2, 3, 4, 5]);
assert_eq!(windowed::<i32>(&[], 3), Vec::<i32>::new());
}
#[test]
fn sendable_targets_excludes_non_transport_sentinels() {
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("zynk.db");
{
let conn = crate::db::open_database(&db_path).unwrap();
conn.execute(
"INSERT INTO projects (project_id, name, root_path, created_at, updated_at)
VALUES ('p1', 'P1', '/tmp/p1', '2026-05-29T00:00:00Z', '2026-05-29T00:00:00Z')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO sessions (
session_id, project_id, title, phase, mode, workflow_status,
created_at, updated_at
)
VALUES (
's1', 'p1', 'S1', 'implementation', 'review', 'working',
'2026-05-29T00:00:00Z', '2026-05-29T00:00:00Z'
)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO audit_records (
audit_id, previous_audit_id, session_id, source_agent_id, target_agent_id,
source_address, target_address, transport, workspace_id, mid, record_type,
command_origin, payload_hash, payload_redaction_policy, content_size,
delivery_status, observed_by, verified_by, timestamp
)
VALUES (
'aud-herdr', NULL, 's1', 'claude', 'codex', 'w-1', 'w-2', 'herdr', 'w',
'm1', 'note', 'agent', 'sha256:test', 'hash-only', 12,
'observed', 'codex', 'agent', '2026-05-29T01:00:00Z'
)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO audit_records (
audit_id, previous_audit_id, session_id, source_agent_id, target_agent_id,
source_address, target_address, transport, workspace_id, mid, record_type,
command_origin, payload_hash, payload_redaction_policy, content_size,
delivery_status, observed_by, verified_by, timestamp
)
VALUES (
'aud-decide', NULL, 's1', 'operator', 'none', 'cli', 'none', 'none', 'none',
'm2', 'gate-decision', 'operator', 'sha256:test', 'full', 12,
'observed', 'operator', 'operator', '2026-05-29T01:01:00Z'
)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO audit_records (
audit_id, previous_audit_id, session_id, source_agent_id, target_agent_id,
source_address, target_address, transport, workspace_id, mid, record_type,
command_origin, payload_hash, payload_redaction_policy, content_size,
delivery_status, observed_by, verified_by, timestamp
)
VALUES (
'aud-reveal', NULL, 's1', 'operator', 'none', 'cli', 'none', 'none', 'none',
'm3', 'reveal', 'operator', 'sha256:test', 'full', 12,
'observed', 'operator', 'operator', '2026-05-29T01:02:00Z'
)",
[],
)
.unwrap();
}
let conn = crate::db::open_read_database(&db_path).unwrap();
let targets = crate::db_dashboard::sendable_targets(&conn, "s1").unwrap();
assert!(
targets.contains(&"claude:w-1".to_string()),
"must include the real herdr source: {targets:?}"
);
assert!(
targets.contains(&"codex:w-2".to_string()),
"must include the real herdr target: {targets:?}"
);
assert!(
!targets.contains(&"none:none".to_string()),
"must not leak the none:none sentinel: {targets:?}"
);
assert!(
!targets.contains(&":".to_string()),
"must not leak an empty agent:address pair: {targets:?}"
);
assert!(
!targets.contains(&"operator:cli".to_string()),
"must not leak operator:cli (decide/reveal source): {targets:?}"
);
assert!(
!targets.iter().any(|t| t.ends_with(":cli")),
"must not leak any *:cli address: {targets:?}"
);
assert!(
!targets.iter().any(|t| t.ends_with(":dashboard")),
"must not leak any *:dashboard address: {targets:?}"
);
}
#[test]
fn artifacts_fails_loud_on_kind_payload_mismatch() {
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("zynk.db");
let conn = crate::db::open_database(&db_path).unwrap();
conn.execute(
"INSERT INTO projects (project_id, name, root_path, created_at, updated_at)
VALUES ('p1', 'P1', '/tmp/p1', '2026-05-29T00:00:00Z', '2026-05-29T00:00:00Z')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO sessions (
session_id, project_id, title, phase, mode, workflow_status,
created_at, updated_at
)
VALUES (
's1', 'p1', 'S1', 'implementation', 'review', 'working',
'2026-05-29T00:00:00Z', '2026-05-29T00:00:00Z'
)",
[],
)
.unwrap();
let non_artifact = crate::work_event::WorkEventPayload::System {
text: "not an artifact payload".into(),
};
let stored = non_artifact.to_storage().unwrap();
conn.execute(
"INSERT INTO work_events
(session_id, actor_agent_id, kind, timestamp, payload, content_hash, created_at)
VALUES ('s1', 'codex', 'artifact', '2026-05-29T00:05:00Z', ?1, 'sha256:bad',
'2026-05-29T00:05:00Z')",
[&stored],
)
.unwrap();
let error = render_artifacts_inner(&conn, Some("s1"))
.expect_err("a kind='artifact' row with a non-Artifact payload must fail loud, not render 'No artifacts'");
assert!(
error.message.contains("render_artifacts") && error.message.contains("non-Artifact"),
"error must name the artifacts kind/payload mismatch: {}",
error.message
);
}
}