diff --git a/AGENTS.md b/AGENTS.md index aaa301f..85a6862 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,8 +99,10 @@ crates/ | `KR0KI_SYSMLV2_BASE_URL` | unset | Live SysML-v2 client test target (`just sysml-api-up` serves one on `127.0.0.1:9000`; `just test-live-sysml`) | | `OPENAI_API_URL` | (in `.env`) | The agent's / `/health`'s LLM. Boot default is Qwen3.8 NEO-CODER + mmproj (vision) on `127.0.0.1:8002/v1` via `deploy/systemd/b00t-hive-inference-heretic-neo-coder.service`; Qwen3.6 units are disabled | | `KR0KI_IDENTITIES_FILE` | unset | Per-identity bearer tokens (stored as SHA-256) and capability grants (`render`, `model.read`, `model.commit`, `assurance.read`, `assurance.propose`, `assurance.verify`, `audit.read`, `ui.steer`, `admin`). When set it **replaces** the shared `KR0KI_AUTH_TOKEN`; every route is classified in `kr0ki-server/src/gateway.rs` and an unclassified route needs `admin`. A tool's visibility or annotations are never what enforces anything | +| `KR0KI_PUBLIC_UI` | unset (off) | `1`/`true`: serve the static `/playbook` UI **without a token** (a browser navigation cannot send an `Authorization` header, so with per-identity grants on the page could not otherwise load). GET only, exact static routes only (`gateway::PUBLIC_UI_PATTERNS`); every data route still needs a token. Enter the token under Playb00k Setup (kept in `sessionStorage` only). Other Playb00k tabs still call the server without a token, so with grants on they need an `anonymous`-style open server or will show auth errors; the **Assurance** tab sends the token | | `KR0KI_AUDIT_FILE` | `./.kr0ki-audit/audit.jsonl` when identities are set | Hash-chained, append-only audit log (one record per invocation, permitted or denied). If a record cannot be written the request is refused (503) | | `KR0KI_ASSURANCE_BASELINE` / `KR0KI_REPO_ROOT` / `KR0KI_EVIDENCE_DIR` / `KR0KI_ASSURANCE_PREFIX` | `docs/assurance/kr0ki.assurance.toml` / `.` / `./.kr0ki-evidence` / `kr0ki:assurance:` | Enable `/assurance/*` (the assurance thread) and where its baseline, repository and evidence live | +| `KR0KI_SYSMD_URL` | unset | SysMD constraint-solver sidecar (`containers/kr0ki-sysmd`, `127.0.0.1:8081`, `just sysmd-image` then `just sysmd-up`); enables `POST /sysmd/solve` (MCP `solve_constraints`) and the `sysmd` health check. See `docs/SYSMD-SIDECAR.md` | | `KR0KI_SYSML_MCP_URL` | unset | SysML v2 MCP sidecar (`containers/kr0ki-sysml-mcp`, `127.0.0.1:8790`); enables `/sysml/{validate,parse,symbols,summary}` and the `sysml_mcp` health check | --- diff --git a/containers/kr0ki-sysmd/.gitignore b/containers/kr0ki-sysmd/.gitignore new file mode 100644 index 0000000..0459f95 --- /dev/null +++ b/containers/kr0ki-sysmd/.gitignore @@ -0,0 +1 @@ +sysmd.jar diff --git a/containers/kr0ki-sysmd/Containerfile b/containers/kr0ki-sysmd/Containerfile new file mode 100644 index 0000000..0d93d64 --- /dev/null +++ b/containers/kr0ki-sysmd/Containerfile @@ -0,0 +1,12 @@ +# kr0ki sidecar: SysMD (tukcps/SysMD, interval constraint solver) running headless on :8081. Black box: kr0ki talks REST only. +# Build: `just sysmd-image` (builds the bootJar from the PromptExecution/SysMD fork, then this image). Needs Java 25. +FROM docker.io/library/eclipse-temurin:25-jre +RUN useradd --system --uid 10001 --home-dir /app --shell /usr/sbin/nologin sysmd && mkdir -p /app/SysMD /tmp/sysmd && chown sysmd /app /app/SysMD /tmp/sysmd +WORKDIR /app +COPY sysmd.jar /app/sysmd.jar +USER sysmd +EXPOSE 8081 +# Run with: --init --memory 1g --read-only --tmpfs /tmp -v kr0ki-sysmd-data:/app/SysMD:U --cap-drop=ALL --security-opt no-new-privileges +# (SysMD keeps each project as files under /app/SysMD; kr0ki treats that as a throwaway store.) +ENV JAVA_TOOL_OPTIONS="-Xmx512m -Djava.io.tmpdir=/tmp/sysmd -Dlogging.file.name=/tmp/sysmd/sysmd.log -Djava.awt.headless=true" +CMD ["java", "-jar", "/app/sysmd.jar", "headless"] diff --git a/crates/kr0ki-core/src/lib.rs b/crates/kr0ki-core/src/lib.rs index 7c14922..30be530 100644 --- a/crates/kr0ki-core/src/lib.rs +++ b/crates/kr0ki-core/src/lib.rs @@ -53,6 +53,7 @@ pub mod reqif_fetch; pub mod reqif_import; pub mod reqif_roundtrip; pub mod sync_engine; +pub mod sysmd_client; pub mod sysml_mcp; pub mod ui_bus; pub mod verification_runner; diff --git a/crates/kr0ki-core/src/mcp_tool.rs b/crates/kr0ki-core/src/mcp_tool.rs index c5f34b7..b928a3f 100644 --- a/crates/kr0ki-core/src/mcp_tool.rs +++ b/crates/kr0ki-core/src/mcp_tool.rs @@ -34,6 +34,7 @@ pub enum McpTool { ValidateSysml, SysmlSymbols, SysmlSummary, + SolveConstraints, ListRequirements, GetRequirement, TraceRequirement, @@ -70,6 +71,7 @@ impl McpTool { Self::ValidateSysml, Self::SysmlSymbols, Self::SysmlSummary, + Self::SolveConstraints, Self::ListRequirements, Self::GetRequirement, Self::TraceRequirement, @@ -106,6 +108,7 @@ impl McpTool { Self::ValidateSysml => "validate_sysml", Self::SysmlSymbols => "sysml_symbols", Self::SysmlSummary => "sysml_summary", + Self::SolveConstraints => "solve_constraints", Self::ListRequirements => "list_requirements", Self::GetRequirement => "get_requirement", Self::TraceRequirement => "trace_requirement", @@ -192,6 +195,13 @@ impl McpTool { Self::SysmlSummary => { "Summarise SysML v2 text: counts of elements by kind (part def, requirement, ...), via the SysML MCP sidecar." } + Self::SolveConstraints => { + "Solve the numeric constraints of a model with the SysMD sidecar (an interval constraint solver). Send SysML v2 / KerML \ + text with attributes that have units and ranges; get back each variable's range as a closed interval (already widened \ + outward for SysMD's 5-digit rounding, so it contains the true range) with its unit, plus the solver's issues and a \ + verdict: consistent, inconsistent (a constraint cannot hold: the values are not a solution) or error (the model did \ + not parse). Read-only. A range that is unbounded or empty is reported as such, never as a number." + } Self::ListRequirements => { "List the assurance baseline's requirements, each with its status, owner, assurance state (unsatisfied / satisfied_untested / verified / failing / stale) and gap kinds. Filters narrow the result so only what is needed enters your context; call get_requirement for one in full." } @@ -232,6 +242,14 @@ impl McpTool { "required": ["code"], "properties": {"code": {"type": "string", "description": "SysML v2 textual notation (max 256 KiB)."}} }), + Self::SolveConstraints => serde_json::json!({ + "type": "object", + "required": ["code"], + "properties": { + "code": {"type": "string", "description": "SysML v2 / KerML / SysMD text (max 256 KiB)."}, + "language": {"type": "string", "enum": ["sysml", "kerml", "sysmd"], "description": "Notation of `code`; default sysml."} + } + }), Self::ListRequirements => serde_json::json!({ "type": "object", "properties": { @@ -433,6 +451,14 @@ impl McpTool { path_template: "/sysml/summary", args: &[ArgBinding { name: "code", placement: ArgPlacement::Body }], }, + Self::SolveConstraints => HttpBinding { + method: HttpMethod::Post, + path_template: "/sysmd/solve", + args: &[ + ArgBinding { name: "code", placement: ArgPlacement::Body }, + ArgBinding { name: "language", placement: ArgPlacement::Query }, + ], + }, Self::ListDiagramTypes => HttpBinding { method: HttpMethod::Get, path_template: "/api/catalog", @@ -875,7 +901,7 @@ mod tests { names.sort_unstable(); names.dedup(); assert_eq!(names.len(), before, "duplicate McpTool name in ALL"); - assert_eq!(McpTool::ALL.len(), 32); + assert_eq!(McpTool::ALL.len(), 33); } #[test] diff --git a/crates/kr0ki-core/src/sysmd_client.rs b/crates/kr0ki-core/src/sysmd_client.rs new file mode 100644 index 0000000..b4d2d91 --- /dev/null +++ b/crates/kr0ki-core/src/sysmd_client.rs @@ -0,0 +1,868 @@ +//! Client for the **SysMD sidecar** (`containers/kr0ki-sysmd`: tukcps/SysMD, an interval constraint solver, run headless on :8081). +//! +//! SysMD is a black box: kr0ki sends SysML v2 / KerML / SysMD text and reads back the solved variables; it never links SysMD +//! code. One call to [`SysmdClient::solve`] is one throwaway project + session, deleted afterwards. +//! +//! # Reading SysMD's numbers soundly +//! SysMD answers with *strings* formatted for people (`Representer`, precision 5), not with intervals. Verified live against +//! SysMD 4.3.0 on 2026-10-05, and in `Representer.kt`, the formatting loses information in ways that matter for a solver: +//! +//! * every bound is rounded to 5 decimals of its mantissa (`0.33333`, `12.34568e6`, `500e-6`); +//! * a value whose bounds are within a relative 1e-5 of each other, or that are both below 5e-6, prints **only the lower +//! bound** (`0..1e-7` prints `0`); +//! * when the upper bound exceeds the lower by more than 1e6 the lower bound prints as a literal `0`; +//! * `*` means unbounded, `∅` empty, `NaN`, and non-numbers (`true`, `Unknown`, dates) are free text. +//! +//! [`parse_value`] therefore never returns a printed number as exact: it *widens outward* by those bounds, so the interval +//! it returns contains the solver's true range. [`Interval`] cannot hold an infinite bound, so unbounded results are a +//! separate variant instead of a poisoned interval. +//! +//! Values are always in SI base units (`5 mm` comes back as `0.005` with unit `m`; `20 °C` as `293.15` with unit `K`), and the +//! unit string is `kg m / s^2`-shaped: factors by whitespace, one `/`, integer `^` powers. Units kr0ki cannot map are reported +//! as such, never guessed. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; +use tokio::sync::{OnceCell, Semaphore}; +use ufo_types::quantity::{Dimension, Interval, Quantity, Unit}; + +/// Model text larger than this is refused before it reaches the sidecar. +pub const MAX_SOURCE_BYTES: usize = 256 * 1024; +const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024; +/// Solves run at most this many at once: each session holds the whole standard library in memory. +const CONCURRENT_SOLVES: usize = 2; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum SysmdError { + #[error("model text is larger than {MAX_SOURCE_BYTES} bytes")] + TooLarge, + #[error("the SysMD sidecar is unavailable: {0}")] + Unavailable(String), + #[error("the SysMD sidecar refused the request ({status}): {body}")] + Rejected { status: u16, body: String }, + #[error("unexpected response from the SysMD sidecar: {0}")] + Protocol(String), +} + +/// The notation of the text sent to SysMD (its `Language::toString()`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SysmdLanguage { + #[default] + Sysml, + Kerml, + Sysmd, +} + +impl SysmdLanguage { + fn wire(self) -> &'static str { + match self { + Self::Sysml => "SysML", + Self::Kerml => "KerML", + Self::Sysmd => "SysMD", + } + } +} + +/// What SysMD said a variable's range is, with every printed number already widened outward (see the module docs). +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ValueRange { + /// A closed, finite interval that contains the solver's range. + Bounded { + range: Interval, + }, + /// At least one bound is infinite (`*`); a finite bound is already widened. + Unbounded { + lo: Option, + hi: Option, + }, + /// `∅`: no value (an unbound variable, or an unsatisfiable one: the issues say which). + Empty, + NotANumber, + /// Not a number at all (`true`, `Unknown`, a date, a string). + Other { + text: String, + }, +} + +/// One unit of imprecision, relative to the numeral's own exponent: SysMD prints 5 decimals of the mantissa. +const MANTISSA_ULP: f64 = 1e-5; +/// Below this magnitude two bounds both print as 0 and SysMD keeps only the lower one. +const ZERO_BAND: f64 = 5e-6; + +struct Numeral { + value: f64, + /// The size of one unit in the last printed place (a decade of the mantissa, times 1e-5). + ulp: f64, +} + +fn parse_numeral(text: &str) -> Option { + // no trimming: SysMD prints numeric ranges as `a..b`; a spaced `a .. b` is its date format + let t = text; + if t.is_empty() + || !t + .bytes() + .all(|b| b.is_ascii_digit() || b"+-.eE".contains(&b)) + { + return None; // also keeps `inf` / `nan`, which Rust's f64 parser would accept, out + } + let value: f64 = t.parse().ok()?; + if !value.is_finite() { + return None; + } + let exponent: i32 = match t.find(['e', 'E']) { + Some(i) => t[i + 1..].parse().ok()?, + None => 0, + }; + Some(Numeral { + value, + ulp: MANTISSA_ULP * 10f64.powi(exponent), + }) +} + +/// Parse one SysMD value string into a range that contains the solver's true range. +pub fn parse_value(raw: &str) -> ValueRange { + let t = raw.trim(); + let other = || ValueRange::Other { text: t.to_owned() }; + match t { + "∅" => return ValueRange::Empty, + "NaN" => return ValueRange::NotANumber, + _ => {} + } + let Some((lo_text, hi_text)) = t.split_once("..") else { + // One numeral: SysMD may have dropped the upper bound (relative 1e-5 apart, or both inside the zero band). + let Some(n) = parse_numeral(t) else { + return other(); + }; + let slack = n.ulp + + MANTISSA_ULP * n.value.abs() + + if n.value.abs() < 2.0 * ZERO_BAND { + ZERO_BAND + } else { + 0.0 + }; + return bounded(n.value - slack, n.value + slack).unwrap_or_else(other); + }; + let side = |s: &str| -> Result, ()> { + if s == "*" { + Ok(None) + } else { + parse_numeral(s).map(Some).ok_or(()) + } + }; + let (Ok(lo), Ok(hi)) = (side(lo_text), side(hi_text)) else { + return other(); + }; + match (lo, hi) { + (None, None) => ValueRange::Unbounded { lo: None, hi: None }, + (Some(lo), None) => ValueRange::Unbounded { + lo: Some(lo.value - lo.ulp), + hi: None, + }, + (None, Some(hi)) => ValueRange::Unbounded { + lo: None, + hi: Some(hi.value + hi.ulp), + }, + (Some(lo), Some(hi)) => { + let mut lo_v = lo.value - lo.ulp; + // A literal `0` lower bound stands for anything in (-hi/1e6, hi/1e6): the printer replaces a tiny minimum by 0. + if lo.value == 0.0 { + lo_v = lo_v.min(-hi.value.abs() * 1e-6); + } + bounded(lo_v, hi.value + hi.ulp).unwrap_or_else(other) + } + } +} + +fn bounded(lo: f64, hi: f64) -> Option { + Interval::new(lo, hi) + .ok() + .map(|range| ValueRange::Bounded { range }) +} + +/// Map SysMD's unit string (`m`, `kg m / s^2`, `1 / s`, `EUR`) to a [`Unit`], or say why not. +pub fn parse_unit(raw: &str) -> Result { + let t = raw.trim(); + if t.is_empty() { + return Err("empty unit".into()); + } + if !t.contains([' ', '/', '^']) { + return Unit::lookup(t).ok_or_else(|| format!("unknown unit '{t}'")); + } + let mut parts = t.split('/'); + let numerator = parts.next().unwrap_or_default(); + let denominator = parts.next().unwrap_or_default(); + if parts.next().is_some() { + return Err(format!("more than one '/' in unit '{t}'")); + } + let mut dimension = Dimension::DIMENSIONLESS; + let mut scale = 1.0_f64; + for (factors, divide) in [(numerator, false), (denominator, true)] { + for token in factors.split_whitespace() { + if token == "1" { + continue; + } + let (symbol, power) = match token.split_once('^') { + Some((s, p)) => ( + s, + p.parse::() + .map_err(|_| format!("bad power in unit '{t}'"))?, + ), + None => (token, 1), + }; + if !(1..=8).contains(&power) { + return Err(format!("unsupported power in unit '{t}'")); + } + let atom = + Unit::lookup(symbol).ok_or_else(|| format!("unknown unit '{symbol}' in '{t}'"))?; + if atom.has_offset() || atom.currency.is_some() { + return Err(format!("'{symbol}' cannot be combined in '{t}'")); + } + for _ in 0..power { + dimension = if divide { + dimension.div(&atom.dimension) + } else { + dimension.mul(&atom.dimension) + } + .map_err(|e| format!("{e} in unit '{t}'"))?; + scale = if divide { + scale / atom.scale + } else { + scale * atom.scale + }; + } + } + } + Unit::new(t, dimension, scale, 0.0, None).map_err(|e| e.to_string()) +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct SolvedVariable { + pub path: String, + /// SysMD's own unit string. + pub unit: String, + /// SysMD's own value string, kept so a reader can see what was parsed. + pub raw: String, + pub range: ValueRange, + /// `Some` when the range is bounded and the unit maps to a ufo-types [`Unit`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub quantity: Option, + /// Why `quantity` is `None` although the range is bounded. + #[serde(skip_serializing_if = "Option::is_none")] + pub unit_problem: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SolveIssue { + pub kind: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "elementId")] + pub element_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SolveVerdict { + /// No issue of kind `ERROR*` or `WARN_INCONSISTENCY`. + Consistent, + /// SysMD reported an inconsistency (a constraint or dependency that cannot hold): the values are not a valid solution. + Inconsistent, + /// The model did not parse or analyse; there are no trustworthy values. + Error, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct SolveReport { + pub verdict: SolveVerdict, + pub issues: Vec, + pub iterations: u32, + pub variables: Vec, +} + +pub fn verdict_of(issues: &[SolveIssue]) -> SolveVerdict { + if issues.iter().any(|i| i.kind.starts_with("ERROR")) { + SolveVerdict::Error + } else if issues.iter().any(|i| i.kind == "WARN_INCONSISTENCY") { + SolveVerdict::Inconsistent + } else { + SolveVerdict::Consistent + } +} + +fn solved(path: String, unit: String, raw: String) -> SolvedVariable { + let range = parse_value(&raw); + let (quantity, unit_problem) = match &range { + ValueRange::Bounded { range } => match parse_unit(&unit) { + Ok(u) => (Some(Quantity::new(*range, u)), None), + Err(e) => (None, Some(e)), + }, + _ => (None, None), + }; + SolvedVariable { + path, + unit, + raw, + range, + quantity, + unit_problem, + } +} + +/// `a/2/1`-style names are solver bookkeeping, not model variables. +fn is_bookkeeping(path: &str) -> bool { + let mut parts = path.rsplitn(3, '/'); + let (last, mid, rest) = (parts.next(), parts.next(), parts.next()); + matches!((last, mid, rest), (Some(l), Some(m), Some(_)) if l.bytes().all(|b| b.is_ascii_digit()) && m.bytes().all(|b| b.is_ascii_digit()) && !l.is_empty() && !m.is_empty()) +} + +struct RawRun { + issues: Vec, + iterations: u32, + variables: Vec<(String, String, String)>, +} + +pub struct SysmdClient { + base: String, + http: reqwest::Client, + slots: Semaphore, + /// Variables of an empty model: the standard library, which every session carries and no caller asked about. + library: OnceCell>, + counter: AtomicU64, +} + +impl SysmdClient { + /// `base` is the sidecar's HTTP root, e.g. `http://127.0.0.1:8081`. + pub fn new(base: impl Into) -> Self { + Self { + base: base.into().trim_end_matches('/').to_owned(), + http: reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .expect("reqwest client builds"), + slots: Semaphore::new(CONCURRENT_SOLVES), + library: OnceCell::new(), + counter: AtomicU64::new(1), + } + } + + pub fn base_url(&self) -> &str { + &self.base + } + + /// `true` when the sidecar answers `GET /projects` (SysMD has no health route of its own). + pub async fn ping(&self) -> Result<(), SysmdError> { + let resp = self + .http + .get(format!("{}/projects", self.base)) + .send() + .await + .map_err(|e| SysmdError::Unavailable(e.to_string()))?; + if resp.status().is_success() { + Ok(()) + } else { + Err(SysmdError::Unavailable(format!( + "GET /projects returned HTTP {}", + resp.status() + ))) + } + } + + /// Solve `code` and report every variable it defines, with ranges parsed soundly and units mapped. + pub async fn solve( + &self, + code: &str, + language: SysmdLanguage, + ) -> Result { + if code.len() > MAX_SOURCE_BYTES { + return Err(SysmdError::TooLarge); + } + let library = self + .library + .get_or_try_init(|| async { + let run = self.run("", SysmdLanguage::Sysml).await?; + Ok::<_, SysmdError>(run.variables.into_iter().map(|(p, _, _)| p).collect()) + }) + .await?; + let run = self.run(code, language).await?; + let variables = run + .variables + .into_iter() + .filter(|(path, _, _)| !library.contains(path) && !is_bookkeeping(path)) + .map(|(path, unit, raw)| solved(path, unit, raw)) + .collect(); + Ok(SolveReport { + verdict: verdict_of(&run.issues), + issues: run.issues, + iterations: run.iterations, + variables, + }) + } + + async fn run(&self, code: &str, language: SysmdLanguage) -> Result { + let _slot = self + .slots + .acquire() + .await + .map_err(|_| SysmdError::Unavailable("shutting down".into()))?; + let name = format!( + "kr0ki-{}-{}", + std::process::id(), + self.counter.fetch_add(1, Ordering::Relaxed) + ); + let project = self + .json( + self.http + .post(format!("{}/projects", self.base)) + .json(&json!({"name": name, "description": "kr0ki solve"})), + ) + .await?; + let project_id = project + .get("@id") + .and_then(Value::as_str) + .ok_or_else(|| SysmdError::Protocol("project response has no @id".into()))? + .to_owned(); + let result = self.run_in_project(&name, code, language).await; + // Best effort: a failed delete only leaks memory until the sidecar restarts, and must not hide the real result. + let _ = self + .http + .delete(format!("{}/projects/{}", self.base, project_id)) + .send() + .await; + result + } + + async fn run_in_project( + &self, + project: &str, + code: &str, + language: SysmdLanguage, + ) -> Result { + let resp = self + .http + .post(format!("{}/session", self.base)) + .header("content-type", "text/plain") + .body(project.to_owned()) + .send() + .await + .map_err(|e| SysmdError::Unavailable(e.to_string()))?; + let session = String::from_utf8(self.read(resp).await?) + .map_err(|_| SysmdError::Protocol("session id is not text".into()))? + .trim() + .to_owned(); + let result = self.run_in_session(&session, code, language).await; + let _ = self + .http + .delete(format!("{}/session", self.base)) + .header("SessionId", &session) + .send() + .await; + result + } + + async fn run_in_session( + &self, + session: &str, + code: &str, + language: SysmdLanguage, + ) -> Result { + let body = json!({"language": language.wire(), "body": code, "runlevel": "ALL"}); + let resp = self + .http + .put(format!("{}/session/model", self.base)) + .header("SessionId", session) + .json(&body) + .send() + .await + .map_err(|e| SysmdError::Unavailable(e.to_string()))?; + // A 500 still carries the issues (SysMD's way of saying "your model did not analyse"), so it is a report, not a failure. + let status = resp.status(); + let bytes = self.read_any(resp).await?; + let status_json: Value = match serde_json::from_slice(&bytes) { + Ok(v) => v, + Err(_) => { + return Err(SysmdError::Rejected { + status: status.as_u16(), + body: String::from_utf8_lossy(&bytes).chars().take(300).collect(), + }) + } + }; + if !status.is_success() && status_json.get("issues").is_none() { + return Err(SysmdError::Rejected { + status: status.as_u16(), + body: status_json.to_string().chars().take(300).collect(), + }); + } + let issues: Vec = + serde_json::from_value(status_json.get("issues").cloned().unwrap_or(json!([]))) + .map_err(|e| SysmdError::Protocol(format!("issues: {e}")))?; + let iterations = status_json + .get("numberOfPropagateIterations") + .and_then(Value::as_u64) + .unwrap_or(0) as u32; + let resp = self + .http + .get(format!("{}/session/variables", self.base)) + .header("SessionId", session) + .send() + .await + .map_err(|e| SysmdError::Unavailable(e.to_string()))?; + // 404 with an empty list is how SysMD says "no variables". + let vars: Value = if resp.status() == reqwest::StatusCode::NOT_FOUND { + json!({"variables": []}) + } else { + serde_json::from_slice(&self.read(resp).await?) + .map_err(|e| SysmdError::Protocol(format!("variables: {e}")))? + }; + let variables = vars + .get("variables") + .and_then(Value::as_array) + .ok_or_else(|| SysmdError::Protocol("no variables array".into()))? + .iter() + .filter_map(|v| { + Some(( + v.get("qualifiedName")?.as_str()?.to_owned(), + v.get("unit") + .and_then(Value::as_str) + .unwrap_or("1") + .to_owned(), + v.get("value")?.as_str()?.to_owned(), + )) + }) + .collect(); + Ok(RawRun { + issues, + iterations, + variables, + }) + } + + async fn json(&self, req: reqwest::RequestBuilder) -> Result { + let resp = req + .send() + .await + .map_err(|e| SysmdError::Unavailable(e.to_string()))?; + serde_json::from_slice(&self.read(resp).await?) + .map_err(|e| SysmdError::Protocol(format!("not JSON: {e}"))) + } + + /// Body of a successful response. + async fn read(&self, resp: reqwest::Response) -> Result, SysmdError> { + let status = resp.status(); + let bytes = self.read_any(resp).await?; + if status.is_success() { + Ok(bytes) + } else { + Err(SysmdError::Rejected { + status: status.as_u16(), + body: String::from_utf8_lossy(&bytes).chars().take(300).collect(), + }) + } + } + + async fn read_any(&self, resp: reqwest::Response) -> Result, SysmdError> { + let bytes = resp + .bytes() + .await + .map_err(|e| SysmdError::Unavailable(e.to_string()))?; + if bytes.len() > MAX_RESPONSE_BYTES { + return Err(SysmdError::Protocol("response is too large".into())); + } + Ok(bytes.to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn bounded_of(v: &ValueRange) -> (f64, f64) { + match v { + ValueRange::Bounded { range } => (range.lo(), range.hi()), + other => panic!("expected a bounded range, got {other:?}"), + } + } + + /// A printed number is never exact: the returned interval must contain it and the true range behind it. + #[test] + fn a_single_numeral_is_widened_not_taken_as_exact() { + let (lo, hi) = bounded_of(&parse_value("6")); + assert!(lo < 6.0 && hi > 6.0, "[{lo}, {hi}]"); + assert!(hi - lo < 1e-3, "widening must stay small: [{lo}, {hi}]"); + // 1/3 printed as 0.33333 (true 0.333333...), 1/7 as 0.14286 (true 0.142857...): rounding up and down both covered + let (lo, hi) = bounded_of(&parse_value("0.33333")); + assert!(lo <= 1.0 / 3.0 && 1.0 / 3.0 <= hi); + let (lo, hi) = bounded_of(&parse_value("0.14286")); + assert!(lo <= 1.0 / 7.0 && 1.0 / 7.0 <= hi); + } + + /// `2.000001` prints as `2`; `0..1e-7` prints as `0` (verified live): both truths must lie inside what we return. + #[test] + fn values_the_printer_collapsed_are_still_contained() { + let (lo, hi) = bounded_of(&parse_value("2")); + assert!(lo <= 2.000001 && 2.000001 <= hi, "[{lo}, {hi}]"); + let (lo, hi) = bounded_of(&parse_value("0")); + assert!(lo <= 0.0 && hi >= 1e-7, "[{lo}, {hi}]"); + let (lo, hi) = bounded_of(&parse_value("1.234e-9")); + assert!(lo <= 1.234e-9 && hi >= 4e-6, "the zero band: [{lo}, {hi}]"); + } + + #[test] + fn engineering_notation_scales_the_widening_with_the_exponent() { + // 12.34568e6 stands for 12345678.9 (printed from 12345678.9): the 5th decimal of the mantissa is worth 10 + let (lo, hi) = bounded_of(&parse_value("12.34568e6")); + assert!(lo <= 12_345_678.9 && 12_345_678.9 <= hi, "[{lo}, {hi}]"); + let (lo, hi) = bounded_of(&parse_value("500e-6")); + assert!(lo <= 0.0005 && 0.0005 <= hi); + assert!(hi - lo < 1e-4); + } + + #[test] + fn a_range_widens_each_bound_and_a_literal_zero_lower_bound_is_not_trusted() { + let (lo, hi) = bounded_of(&parse_value("0.1..0.3")); + assert!(lo < 0.1 && 0.3 < hi); + // `1..1e9` prints as `0..1e9` (verified live): the true lower bound 1 is inside; a tiny negative one cannot be excluded + let (lo, hi) = bounded_of(&parse_value("0..1e9")); + assert!(lo <= 0.0 && hi >= 1e9, "[{lo}, {hi}]"); + assert!(lo >= -1e4, "and not absurdly loose: {lo}"); + } + + #[test] + fn unbounded_empty_nan_and_free_text_are_not_numbers() { + assert_eq!( + parse_value("*..*"), + ValueRange::Unbounded { lo: None, hi: None } + ); + assert!( + matches!(parse_value("3..*"), ValueRange::Unbounded { lo: Some(l), hi: None } if l < 3.0 && l > 2.999) + ); + assert!( + matches!(parse_value("*..-1e3"), ValueRange::Unbounded { lo: None, hi: Some(h) } if h > -1e3) + ); + assert_eq!(parse_value("∅"), ValueRange::Empty); + assert_eq!(parse_value("NaN"), ValueRange::NotANumber); + for text in [ + "Unknown", + "true", + "inf", + "nan", + "1 .. 2", + "2026-10-05 .. 2026-10-06", + "", + "1e999", + "--3", + ] { + assert!( + matches!(parse_value(text), ValueRange::Other { .. }), + "{text:?} -> {:?}", + parse_value(text) + ); + } + } + + #[test] + fn units_are_mapped_by_dimension_and_unknown_ones_are_reported() { + let n = parse_unit("kg m / s^2").unwrap(); + assert_eq!( + (n.dimension.mass, n.dimension.length, n.dimension.time), + (1, 1, -2) + ); + assert_eq!(n.scale, 1.0); + let w = parse_unit("kg m^2 / s^3").unwrap(); + assert_eq!( + (w.dimension.mass, w.dimension.length, w.dimension.time), + (1, 2, -3) + ); + let hz = parse_unit("1 / s").unwrap(); + assert_eq!((hz.dimension.length, hz.dimension.time), (0, -1)); + assert_eq!(parse_unit("m^2").unwrap().dimension.length, 2); + assert_eq!(parse_unit("1").unwrap(), Unit::one()); + assert_eq!( + parse_unit("EUR").unwrap().currency.map(|c| c.to_string()), + Some("EUR".into()) + ); + assert!(parse_unit("furlong").unwrap_err().contains("furlong")); + assert!(parse_unit("m / s / s").is_err()); + assert!(parse_unit("m^99").is_err()); + assert!(parse_unit("EUR / h").is_err(), "money does not combine"); + assert!(parse_unit("").is_err()); + } + + #[test] + fn a_bounded_value_with_an_unknown_unit_is_kept_but_explained() { + let v = solved("x".into(), "furlong".into(), "2".into()); + assert!(v.quantity.is_none()); + assert!(v.unit_problem.unwrap().contains("furlong")); + assert!(matches!(v.range, ValueRange::Bounded { .. })); + let v = solved("y".into(), "m".into(), "0.1..0.3".into()); + assert_eq!(v.quantity.unwrap().unit.dimension.length, 1); + } + + #[test] + fn verdict_follows_the_issue_kinds() { + let issue = |k: &str| SolveIssue { + kind: k.into(), + message: String::new(), + element_id: None, + }; + assert_eq!(verdict_of(&[]), SolveVerdict::Consistent); + assert_eq!(verdict_of(&[issue("WARN_UNIT")]), SolveVerdict::Consistent); + assert_eq!( + verdict_of(&[issue("WARN_INCONSISTENCY")]), + SolveVerdict::Inconsistent + ); + assert_eq!( + verdict_of(&[issue("WARN_INCONSISTENCY"), issue("ERROR_SYNTACTICAL")]), + SolveVerdict::Error + ); + } + + #[test] + fn solver_bookkeeping_names_are_recognised() { + assert!(is_bookkeeping("a/2/1")); + assert!(is_bookkeeping("b::inner/12/3")); + assert!(!is_bookkeeping("a")); + assert!(!is_bookkeeping("b::mass")); + assert!(!is_bookkeeping("a/x/1")); + } + + async fn sysmd(model_status: u16, model_body: Value, variables: Value) -> MockServer { + let s = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/projects")) + .respond_with( + ResponseTemplate::new(201).set_body_json(json!({"@id": "P1", "name": "n"})), + ) + .mount(&s) + .await; + Mock::given(method("POST")) + .and(path("/session")) + .and(header("content-type", "text/plain")) + .respond_with(ResponseTemplate::new(201).set_body_string("S1")) + .mount(&s) + .await; + Mock::given(method("PUT")) + .and(path("/session/model")) + .and(header("SessionId", "S1")) + .respond_with(ResponseTemplate::new(model_status).set_body_json(model_body)) + .mount(&s) + .await; + Mock::given(method("GET")) + .and(path("/session/variables")) + .respond_with(ResponseTemplate::new(200).set_body_json(variables)) + .mount(&s) + .await; + Mock::given(method("DELETE")) + .and(path("/session")) + .respond_with(ResponseTemplate::new(200)) + .mount(&s) + .await; + Mock::given(method("DELETE")) + .and(path("/projects/P1")) + .respond_with(ResponseTemplate::new(200)) + .mount(&s) + .await; + s + } + + #[tokio::test] + async fn solve_drops_library_variables_and_bookkeeping_and_cleans_up() { + let vars = json!({"variables": [ + {"qualifiedName": "Base::things::multiplicity", "unit": "1", "value": "*..*"}, + {"qualifiedName": "w", "unit": "m", "value": "0.2..0.6"}, + {"qualifiedName": "w/2/1", "unit": "1", "value": "*..*"}, + ]}); + let s = sysmd( + 200, + json!({"issues": [], "numberOfPropagateIterations": 4, "updates": {}}), + vars, + ) + .await; + let c = SysmdClient::new(s.uri()); + let report = c + .solve( + "attribute w: ISQ::LengthValue = 0.1 .. 0.3;", + SysmdLanguage::Sysml, + ) + .await + .unwrap(); + // the mock answers the baseline run with the same variables, so `w` is library there; use a second client view + // to prove the filter: everything the baseline saw is dropped, nothing else is + assert!(report.variables.is_empty()); + assert_eq!(report.verdict, SolveVerdict::Consistent); + let deletes = s + .received_requests() + .await + .unwrap() + .iter() + .filter(|r| r.method.as_str() == "DELETE") + .count(); + assert_eq!( + deletes, 4, + "baseline run and solve each delete their session and project" + ); + } + + #[tokio::test] + async fn a_500_with_issues_is_a_report_not_an_error() { + let s = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/projects")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({"@id": "P"}))) + .mount(&s) + .await; + Mock::given(method("POST")) + .and(path("/session")) + .respond_with(ResponseTemplate::new(201).set_body_string("S")) + .mount(&s) + .await; + // the empty baseline model succeeds, the user's model does not + Mock::given(method("PUT")).and(path("/session/model")) + .respond_with(|req: &wiremock::Request| { + let body: Value = serde_json::from_slice(&req.body).unwrap(); + if body["body"] == "" { + ResponseTemplate::new(200).set_body_json(json!({"issues": [], "numberOfPropagateIterations": 1, "updates": {}})) + } else { + ResponseTemplate::new(500).set_body_json(json!({"issues": [{"kind": "ERROR_SYNTACTICAL", "message": "after '[': expected ']'", "line": 1}], "numberOfPropagateIterations": 0, "updates": {}})) + } + }) + .mount(&s).await; + Mock::given(method("GET")) + .and(path("/session/variables")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({"variables": []}))) + .mount(&s) + .await; + Mock::given(method("DELETE")) + .respond_with(ResponseTemplate::new(200)) + .mount(&s) + .await; + let c = SysmdClient::new(s.uri()); + let report = c.solve("part x {{{", SysmdLanguage::Sysml).await.unwrap(); + assert_eq!(report.verdict, SolveVerdict::Error); + assert_eq!(report.issues[0].kind, "ERROR_SYNTACTICAL"); + assert!(report.variables.is_empty()); + } + + #[tokio::test] + async fn too_large_and_unreachable_are_distinct_errors() { + let c = SysmdClient::new("http://127.0.0.1:9"); + let big = "x".repeat(MAX_SOURCE_BYTES + 1); + assert_eq!( + c.solve(&big, SysmdLanguage::Sysml).await.unwrap_err(), + SysmdError::TooLarge + ); + assert!(matches!( + c.solve("x", SysmdLanguage::Sysml).await.unwrap_err(), + SysmdError::Unavailable(_) + )); + assert!(matches!( + c.ping().await.unwrap_err(), + SysmdError::Unavailable(_) + )); + } +} diff --git a/crates/kr0ki-core/tests/sysmd_live.rs b/crates/kr0ki-core/tests/sysmd_live.rs new file mode 100644 index 0000000..aea0a77 --- /dev/null +++ b/crates/kr0ki-core/tests/sysmd_live.rs @@ -0,0 +1,128 @@ +//! Live test against a real SysMD sidecar. Ignored by default: `just test-live-sysmd` (starts the image) or +//! `KR0KI_SYSMD_URL=http://127.0.0.1:8081 cargo test -p kr0ki-core --test sysmd_live -- --ignored`. + +use kr0ki_core::sysmd_client::{ + SolveVerdict, SolvedVariable, SysmdClient, SysmdLanguage, ValueRange, +}; + +fn client() -> SysmdClient { + let url = + std::env::var("KR0KI_SYSMD_URL").expect("set KR0KI_SYSMD_URL to run the live SysMD test"); + SysmdClient::new(url) +} + +fn var<'a>(vars: &'a [SolvedVariable], path: &str) -> &'a SolvedVariable { + vars.iter().find(|v| v.path == path).unwrap_or_else(|| { + panic!( + "no variable {path}; have {:?}", + vars.iter().map(|v| &v.path).collect::>() + ) + }) +} + +fn contains(v: &SolvedVariable, truth: f64) -> bool { + match &v.range { + ValueRange::Bounded { range } => range.lo() <= truth && truth <= range.hi(), + _ => false, + } +} + +#[tokio::test] +#[ignore = "needs a SysMD sidecar (KR0KI_SYSMD_URL)"] +async fn solves_a_model_with_units_and_the_ranges_contain_the_truth() { + let c = client(); + c.ping().await.unwrap(); + let r = c + .solve( + "attribute m: ISQ::MassValue = 2.0 [kg]; attribute n: ISQ::MassValue = m * 3.0; \ + attribute l: ISQ::LengthValue = 0.1 .. 0.3 [m]; attribute w: ISQ::LengthValue = l * 2.0; \ + attribute q: ScalarValues::Real = 1.0 / 3.0; attribute a: ScalarValues::Real = 2.000001; \ + attribute z: ScalarValues::Real = 0.0 .. 0.0000001; attribute mm: ISQ::LengthValue = 5.0 [mm]; \ + attribute area: ISQ::AreaValue = l * l; attribute f: ISQ::ForceValue = 4.0 [N]; \ + attribute big: ISQ::MassValue = 123456.789 [kg];", + SysmdLanguage::Sysml, + ) + .await + .unwrap(); + assert_eq!(r.verdict, SolveVerdict::Consistent, "{:?}", r.issues); + + let n = var(&r.variables, "n"); + assert!(contains(n, 6.0), "{n:?}"); + assert_eq!(n.quantity.as_ref().unwrap().unit.dimension.mass, 1); + let w = var(&r.variables, "w"); + assert!(contains(w, 0.2) && contains(w, 0.6), "{w:?}"); + assert_eq!(w.quantity.as_ref().unwrap().unit.dimension.length, 1); + assert!(contains(var(&r.variables, "q"), 1.0 / 3.0)); + // SysMD printed `2` for 2.000001 and `0` for 0..1e-7: the widened intervals must still hold the truth + assert!(contains(var(&r.variables, "a"), 2.000001)); + let z = var(&r.variables, "z"); + assert!(contains(z, 0.0) && contains(z, 1e-7), "{z:?}"); + // 5 mm comes back in the SI base unit + assert!(contains(var(&r.variables, "mm"), 0.005)); + assert!(contains(var(&r.variables, "big"), 123456.789)); + // compound units are mapped by dimension + let area = var(&r.variables, "area"); + assert_eq!( + area.quantity.as_ref().unwrap().unit.dimension.length, + 2, + "{area:?}" + ); + let f = var(&r.variables, "f"); + let u = &f.quantity.as_ref().unwrap().unit; + assert_eq!( + (u.dimension.mass, u.dimension.length, u.dimension.time), + (1, 1, -2), + "{f:?}" + ); + // none of the standard library leaks into the answer + assert!(r.variables.iter().all(|v| !v.path.starts_with("Base::") + && !v.path.contains("multiplicity") + && !v.path.starts_with("ISQ::"))); +} + +#[tokio::test] +#[ignore = "needs a SysMD sidecar (KR0KI_SYSMD_URL)"] +async fn a_syntax_error_is_an_error_verdict_and_an_unsatisfiable_model_is_inconsistent() { + let c = client(); + let bad = c.solve("part x {{{", SysmdLanguage::Sysml).await.unwrap(); + assert_eq!(bad.verdict, SolveVerdict::Error, "{:?}", bad.issues); + assert!(!bad.issues.is_empty()); + + let r = c + .solve( + "attribute u: ScalarValues::Real; attribute v: ScalarValues::Real = u / 0.0;", + SysmdLanguage::Sysml, + ) + .await + .unwrap(); + assert_eq!(r.verdict, SolveVerdict::Inconsistent, "{:?}", r.issues); +} + +#[tokio::test] +#[ignore = "needs a SysMD sidecar (KR0KI_SYSMD_URL)"] +async fn nested_parts_report_their_paths_and_sessions_are_released() { + let c = client(); + let r = c + .solve( + "part def B { attribute mass: ISQ::MassValue; } part b: B { :>> mass = 2.0 [kg]; part inner: B { :>> mass = 1.5 [kg]; } }", + SysmdLanguage::Sysml, + ) + .await + .unwrap(); + assert_eq!(r.verdict, SolveVerdict::Consistent, "{:?}", r.issues); + assert!(contains(var(&r.variables, "b::mass"), 2.0)); + assert!(contains(var(&r.variables, "b::inner::mass"), 1.5)); + // every solve deletes its session: none are left behind + let base = std::env::var("KR0KI_SYSMD_URL").unwrap(); + let sessions: serde_json::Value = reqwest::get(format!("{base}/session")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + sessions.as_array().unwrap().len(), + 0, + "leaked sessions: {sessions}" + ); +} diff --git a/crates/kr0ki-server/src/app.rs b/crates/kr0ki-server/src/app.rs index 847dc38..81d0a17 100644 --- a/crates/kr0ki-server/src/app.rs +++ b/crates/kr0ki-server/src/app.rs @@ -63,6 +63,8 @@ pub struct AppState { pub brand_dir: PathBuf, /// SysML v2 MCP sidecar client (`KR0KI_SYSML_MCP_URL`); `None` makes `/sysml/*` return 503. pub sysml_mcp: Option>, + /// SysMD constraint-solver sidecar client (`KR0KI_SYSMD_URL`); `None` makes `/sysmd/solve` return 503. + pub sysmd: Option>, /// AG-UI storyb00k sidecar base URL (deep /health probe). `None` skips it. pub storyb00k_agent_url: Option, /// OpenAI-compatible LLM endpoint for the storyb00k agent. The /health LLM @@ -117,6 +119,7 @@ pub fn router_with_gateway( .route("/sparql", post(sparql_query)) .route("/sparql/shapes", post(sparql_shapes)) .route("/sparql/rollup", post(sparql_rollup)) + .route("/sysmd/solve", post(sysmd_solve)) .route( "/sysml/validate", post(|s: State, b: Bytes| sysml_tool(s, "validate", b)), @@ -1779,6 +1782,64 @@ async fn sysml_tool(State(state): State, tool: &'static str, body: Byt } } +#[derive(serde::Deserialize)] +struct SysmdSolveQuery { + language: Option, +} + +/// `POST /sysmd/solve[?language=sysml|kerml|sysmd]` — body is model text; solved by the SysMD sidecar. Read-only: nothing is +/// stored in SysMD (the throwaway project and session are deleted). Ranges are widened outward, see `kr0ki_core::sysmd_client`. +async fn sysmd_solve( + State(state): State, + Query(q): Query, + body: Bytes, +) -> Response { + use kr0ki_core::sysmd_client::{SysmdError, SysmdLanguage}; + let Some(client) = state.sysmd.as_ref() else { + return error_json( + StatusCode::SERVICE_UNAVAILABLE, + "sysmd_not_configured", + "set KR0KI_SYSMD_URL to the SysMD sidecar (just sysmd-up)", + ); + }; + let language = match q + .language + .as_deref() + .map(str::to_ascii_lowercase) + .as_deref() + { + None | Some("sysml") => SysmdLanguage::Sysml, + Some("kerml") => SysmdLanguage::Kerml, + Some("sysmd") => SysmdLanguage::Sysmd, + Some(_) => { + return error_json( + StatusCode::BAD_REQUEST, + "invalid_language", + "language must be sysml, kerml or sysmd", + ) + } + }; + let Ok(code) = std::str::from_utf8(&body) else { + return error_json( + StatusCode::BAD_REQUEST, + "invalid_utf8", + "model text must be UTF-8", + ); + }; + match client.solve(code, language).await { + Ok(report) => Json(report).into_response(), + Err(SysmdError::TooLarge) => error_json( + StatusCode::PAYLOAD_TOO_LARGE, + "sysmd_source_too_large", + "model text is limited to 256 KiB", + ), + Err(e @ (SysmdError::Unavailable(_) | SysmdError::Protocol(_))) => { + error_json(StatusCode::BAD_GATEWAY, "sysmd_unavailable", &e.to_string()) + } + Err(e) => error_json(StatusCode::BAD_GATEWAY, "sysmd_rejected", &e.to_string()), + } +} + const MAX_BRAND_BYTES: u64 = 256 * 1024; fn valid_brand_name(name: &str) -> bool { diff --git a/crates/kr0ki-server/src/gateway.rs b/crates/kr0ki-server/src/gateway.rs index 665812c..46c2851 100644 --- a/crates/kr0ki-server/src/gateway.rs +++ b/crates/kr0ki-server/src/gateway.rs @@ -231,6 +231,7 @@ pub const ROUTES: &[(&str, &str, Option<&str>)] = &[ ("POST", "/sysml/parse", Some("model.read")), ("POST", "/sysml/symbols", Some("model.read")), ("POST", "/sysml/summary", Some("model.read")), + ("POST", "/sysmd/solve", Some("model.read")), ("POST", "/requirements/import", Some("model.read")), ("POST", "/requirements/import/url", Some("model.read")), ("POST", "/requirements/export", Some("model.read")), @@ -311,6 +312,16 @@ pub const ROUTES: &[(&str, &str, Option<&str>)] = &[ ("GET", "/assurance/skills/:name", Some("assurance.read")), ]; +/// The static Playb00k UI. With `KR0KI_PUBLIC_UI` on these `GET` routes skip authentication and audit, because a +/// browser navigation cannot send an `Authorization` header, so the page could not otherwise load. They serve +/// built JavaScript and example metadata only; every data route stays behind the gateway. +pub const PUBLIC_UI_PATTERNS: &[&str] = &[ + "/playbook", + "/playbook/", + "/playbook/*path", + "/playbook/api/examples.json", +]; + /// The grant a `(method, pattern)` requires. Unknown → `admin` (deny by default). pub fn required_grant(method: &str, pattern: Option<&str>) -> Option<&'static str> { let Some(pattern) = pattern else { @@ -343,6 +354,7 @@ pub struct Gateway { audit: Option>, tools: BTreeMap<(String, String), &'static str>, model_revision: Option Option + Send + Sync>>, + public_ui: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -358,9 +370,17 @@ impl Gateway { audit, tools: tool_names(), model_revision: None, + public_ui: false, } } + /// Serve the static Playb00k UI (`PUBLIC_UI_PATTERNS`) without a token. Off by default: an operator opts in. + #[allow(dead_code)] // used by main.rs and tests/assurance_a06.rs; other test crates include this file without it + pub fn with_public_ui(mut self, on: bool) -> Self { + self.public_ui = on; + self + } + /// How to learn "the model revision in play" for routes that name none in the request /// (the assurance routes evaluate against the service's current model revision). pub fn with_model_revision( @@ -476,6 +496,17 @@ pub async fn gateway_middleware(req: Request, next: Next, gateway: Arc) .extensions() .get::() .map(|m| m.as_str().to_string()); + // Opt-in: the static UI loads without a token (a browser navigation cannot send one). GET only, + // and only the exact patterns listed; everything else, including a POST to a /playbook path, is + // authenticated and audited as usual. + if gateway.public_ui + && method == Method::GET + && pattern + .as_deref() + .is_some_and(|p| PUBLIC_UI_PATTERNS.contains(&p)) + { + return next.run(req).await; + } let transport = match req .headers() .get("x-kr0ki-transport") @@ -621,6 +652,20 @@ mod tests { ); } + #[test] + fn the_public_ui_patterns_are_only_static_playbook_gets_and_hold_no_grant() { + for p in PUBLIC_UI_PATTERNS { + assert!(p.starts_with("/playbook"), "{p}"); + let row = ROUTES.iter().find(|(m, rp, _)| *m == "GET" && rp == p); + assert!(row.is_some(), "{p} is not a registered GET route"); + assert_eq!(row.unwrap().2, None, "{p} must need no grant"); + } + // Nothing that serves model or assurance data may ever be listed. + assert!(PUBLIC_UI_PATTERNS + .iter() + .all(|p| !p.contains("assurance") && !p.contains("model") && !p.contains("audit"))); + } + #[test] fn grants_in_the_table_are_all_known() { for (m, p, g) in ROUTES { diff --git a/crates/kr0ki-server/src/health.rs b/crates/kr0ki-server/src/health.rs index a69ad9e..819dab2 100644 --- a/crates/kr0ki-server/src/health.rs +++ b/crates/kr0ki-server/src/health.rs @@ -41,6 +41,8 @@ pub struct Checks { pub storyb00k_agent: Option, /// SysML v2 MCP sidecar (`KR0KI_SYSML_MCP_URL`), if configured. pub sysml_mcp: Option, + /// SysMD constraint-solver sidecar (`KR0KI_SYSMD_URL`), if configured. Probed with `GET /projects` (SysMD has no health route). + pub sysmd: Option, /// OpenAI-compatible LLM endpoint — model COUNT only, never an inference /// request. `configured=false` when OPENAI_API_KEY/URL are unset. pub llm: LlmCheck, @@ -233,11 +235,12 @@ pub async fn collect(state: &AppState) -> HealthReport { let kubediagram_url = state.kubediagram_worker_url.clone(); let storyb00k_url = state.storyb00k_agent_url.clone(); let sysml_mcp_url = state.sysml_mcp.as_ref().map(|c| c.base_url().to_owned()); + let sysmd_url = state.sysmd.as_ref().map(|c| c.base_url().to_owned()); let llm_base = state.llm_api_url.clone(); let llm_key = state.llm_api_key.clone(); let kroki_url = format!("{backend_url}/health"); - let (kroki, kubediagram, storyb00k, sysml_mcp, llm, cache_dir) = tokio::join!( + let (kroki, kubediagram, storyb00k, sysml_mcp, sysmd, llm, cache_dir) = tokio::join!( probe_json(&http, &kroki_url, None), async { match kubediagram_url.as_deref() { @@ -257,6 +260,12 @@ pub async fn collect(state: &AppState) -> HealthReport { None => None, } }, + async { + match sysmd_url.as_deref() { + Some(url) => Some(probe_json(&http, &format!("{url}/projects"), None).await), + None => None, + } + }, async { match llm_base.as_deref() { Some(url) => Some(check_llm(&http, url, llm_key.as_deref()).await), @@ -303,6 +312,7 @@ pub async fn collect(state: &AppState) -> HealthReport { sysml_mcp: sysml_mcp_url .as_deref() .map(|url| to_dep(url, sysml_mcp.unwrap())), + sysmd: sysmd_url.as_deref().map(|url| to_dep(url, sysmd.unwrap())), llm, stores: StoresCheck { cache_dir, diff --git a/crates/kr0ki-server/src/main.rs b/crates/kr0ki-server/src/main.rs index 9833a07..3d09ea3 100644 --- a/crates/kr0ki-server/src/main.rs +++ b/crates/kr0ki-server/src/main.rs @@ -2,6 +2,8 @@ //! //! Config (env): //! KR0KI_BIND default 0.0.0.0:8787 +//! KR0KI_PUBLIC_UI `1`/`true`: serve the static /playbook UI without a token (a browser navigation cannot +//! send one); data routes still need it. Default off. //! KR0KI_CACHE_DIR default ./.kr0ki-cache //! KR0KI_BACKEND_URL default http://127.0.0.1:8010 (private SECURE-mode Kroki) //! KR0KI_AUTH_TOKEN if set, require `Authorization: Bearer ` on every @@ -21,6 +23,7 @@ //! http://127.0.0.1:8788. Unset disables the //! route with a 503, not a panic. //! KR0KI_SYSMLV2_BASE_URL if set, enables read-only `/model/*` routes. +//! KR0KI_SYSMD_URL SysMD constraint-solver sidecar (enables /sysmd/solve). //! KR0KI_SYSML_MCP_URL SysML v2 MCP sidecar (enables /sysml/validate, /sysml/parse, /sysml/symbols, /sysml/summary). //! KR0KI_BRAND_DIR brand packages for `?brand=` (default ./brand; //brand.json). //! KR0KI_SYSMLV2_TOKEN optional bearer token for that model server. @@ -75,6 +78,11 @@ async fn main() -> anyhow::Result<()> { .ok() .filter(|u| !u.trim().is_empty()) .map(|u| Arc::new(kr0ki_core::sysml_mcp::SysmlMcpClient::new(u))); + // SysMD constraint-solver sidecar (containers/kr0ki-sysmd): interval solving of unit-carrying models. + let sysmd = std::env::var("KR0KI_SYSMD_URL") + .ok() + .filter(|u| !u.trim().is_empty()) + .map(|u| Arc::new(kr0ki_core::sysmd_client::SysmdClient::new(u))); let storyb00k_agent_url = std::env::var("KR0KI_STORYB00K_AGENT_URL").ok(); let llm_api_url = std::env::var("OPENAI_API_URL").ok(); let llm_api_key = std::env::var("OPENAI_API_KEY").ok(); @@ -198,7 +206,14 @@ async fn main() -> anyhow::Result<()> { .unwrap_or_else(|_| "kr0ki:assurance:".into()), }) }); - let mut gateway = Gateway::new(auth_mode, audit.clone()); + let public_ui = matches!( + std::env::var("KR0KI_PUBLIC_UI").as_deref(), + Ok("1") | Ok("true") + ); + if public_ui { + tracing::warn!("KR0KI_PUBLIC_UI is on: the static /playbook UI is served without a token (data routes still need one)"); + } + let mut gateway = Gateway::new(auth_mode, audit.clone()).with_public_ui(public_ui); if let Some(rt) = &assurance { let service = rt.service.clone(); gateway = gateway.with_model_revision(move || service.model_revision()); @@ -216,6 +231,7 @@ async fn main() -> anyhow::Result<()> { ui_bus: Arc::new(kr0ki_core::ui_bus::UiBus::new()), brand_dir, sysml_mcp, + sysmd, storyb00k_agent_url, llm_api_url, llm_api_key, diff --git a/crates/kr0ki-server/tests/assurance_a06.rs b/crates/kr0ki-server/tests/assurance_a06.rs index ba5fae8..c8ba7d7 100644 --- a/crates/kr0ki-server/tests/assurance_a06.rs +++ b/crates/kr0ki-server/tests/assurance_a06.rs @@ -216,3 +216,61 @@ async fn resource_reads_pass_through_the_same_gateway_as_the_equivalent_http_req .unwrap(); assert!(p.contains("trace_requirement with id=KR-A06"), "{prompt}"); } + +#[tokio::test] +async fn the_static_ui_needs_a_token_unless_the_operator_opts_in_and_data_never_does() { + // Default: nothing is public, exactly as before. + let h = start("a06-ui-default").await; + let (s, _) = http( + Method::GET, + &format!("{}/playbook/", h.base), + "", + None, + None, + ) + .await; + assert_eq!( + s, 401, + "the static UI is behind the gateway unless KR0KI_PUBLIC_UI is on" + ); + + // Opted in: the static UI loads without a token... + let h = start_with("a06-ui-public", true).await; + let resp = reqwest::get(format!("{}/playbook/", h.base)).await.unwrap(); + assert_ne!( + resp.status().as_u16(), + 401, + "the static UI must load without a token when opted in" + ); + // ...but every data route still needs one, whatever its path looks like. + for path in [ + "/assurance/requirements", + "/assurance/audit", + "/assurance/view?format=svg", + "/mcp/tools", + "/model/projects", + "/playbook-not-a-route/x", + ] { + let resp = reqwest::get(format!("{}{path}", h.base)).await.unwrap(); + assert_eq!( + resp.status().as_u16(), + 401, + "{path} must still require a token" + ); + } + // A POST to a /playbook path is not the static UI. + let resp = reqwest::Client::new() + .post(format!("{}/playbook/x", h.base)) + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 401); + // Public static reads are not audited (they are not tool invocations). + assert!(h + .audit + .read_all() + .unwrap() + .iter() + .all(|r| !r.path.starts_with("/playbook/") + || r.decision == kr0ki_core::audit_log::Decision::Deny)); +} diff --git a/crates/kr0ki-server/tests/b00t_graph_live.rs b/crates/kr0ki-server/tests/b00t_graph_live.rs index b4c2196..72025bc 100644 --- a/crates/kr0ki-server/tests/b00t_graph_live.rs +++ b/crates/kr0ki-server/tests/b00t_graph_live.rs @@ -60,6 +60,7 @@ async fn b00t_graph_renders_a_real_turtle_fixture_to_svg() { ui_bus: Arc::new(kr0ki_core::ui_bus::UiBus::new()), brand_dir: std::env::temp_dir().join("kr0ki-no-brands"), sysml_mcp: None, + sysmd: None, storyb00k_agent_url: None, llm_api_url: None, llm_api_key: None, diff --git a/crates/kr0ki-server/tests/common/assurance_harness.rs b/crates/kr0ki-server/tests/common/assurance_harness.rs index da9ec19..e688ef1 100644 --- a/crates/kr0ki-server/tests/common/assurance_harness.rs +++ b/crates/kr0ki-server/tests/common/assurance_harness.rs @@ -64,6 +64,11 @@ pub struct Harness { /// Start the server. `tag` keeps concurrent tests' files apart. pub async fn start(tag: &str) -> Harness { + start_with(tag, false).await +} + +/// `public_ui`: serve the static /playbook routes without a token. +pub async fn start_with(tag: &str, public_ui: bool) -> Harness { let tmp = std::env::temp_dir().join(format!("kr0ki-gw-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); @@ -102,6 +107,7 @@ pub async fn start(tag: &str) -> Harness { ui_bus: Arc::new(kr0ki_core::ui_bus::UiBus::new()), brand_dir: tmp.join("no-brands"), sysml_mcp: None, + sysmd: None, storyb00k_agent_url: None, llm_api_url: None, llm_api_key: None, @@ -128,6 +134,7 @@ pub async fn start(tag: &str) -> Harness { let svc = assurance_service.clone(); let gateway = Arc::new( Gateway::new(AuthMode::Identities(identities), Some(audit.clone())) + .with_public_ui(public_ui) .with_model_revision(move || svc.model_revision()), ); let app = router_with_gateway(state, gateway, contract); diff --git a/crates/kr0ki-server/tests/http.rs b/crates/kr0ki-server/tests/http.rs index 7c97819..7fb9380 100644 --- a/crates/kr0ki-server/tests/http.rs +++ b/crates/kr0ki-server/tests/http.rs @@ -108,6 +108,7 @@ fn test_state(tag: &str) -> AppState { ui_bus: Arc::new(kr0ki_core::ui_bus::UiBus::new()), brand_dir: std::env::temp_dir().join("kr0ki-no-brands"), sysml_mcp: None, + sysmd: None, storyb00k_agent_url: None, llm_api_url: None, llm_api_key: None, @@ -273,7 +274,7 @@ async fn mcp_tools_lists_all_tools_with_bindings() { let (status, body) = body_string(resp).await; assert_eq!(status, StatusCode::OK); let tools: Vec = serde_json::from_str(&body).unwrap(); - assert_eq!(tools.len(), 32); + assert_eq!(tools.len(), 33); let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); assert!(names.contains(&"render_diagram")); assert!(names.contains(&"list_formats")); @@ -2401,6 +2402,201 @@ async fn health_reports_the_sysml_sidecar_when_configured_and_omits_it_otherwise assert!(without["checks"]["sysml_mcp"].is_null()); } +fn state_with_sysmd(tag: &str, url: &str) -> AppState { + let mut state = test_state(tag); + state.sysmd = Some(Arc::new(kr0ki_core::sysmd_client::SysmdClient::new(url))); + state +} + +/// A SysMD stand-in. The first session it hands out is the library-baseline run (an empty model), the second the caller's. +async fn sysmd_sidecar( + model_status: u16, + issues: serde_json::Value, + user_variables: serde_json::Value, +) -> wiremock::MockServer { + use std::sync::atomic::{AtomicUsize, Ordering}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, Request as WmRequest, ResponseTemplate}; + let s = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/projects")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) + .mount(&s) + .await; + Mock::given(method("POST")) + .and(path("/projects")) + .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"@id": "P1"}))) + .mount(&s) + .await; + let n = Arc::new(AtomicUsize::new(0)); + Mock::given(method("POST")) + .and(path("/session")) + .respond_with(move |_: &WmRequest| { + let id = if n.fetch_add(1, Ordering::SeqCst) == 0 { + "BASE" + } else { + "USER" + }; + ResponseTemplate::new(201).set_body_string(id) + }) + .mount(&s) + .await; + Mock::given(method("PUT")).and(path("/session/model")) + .respond_with(move |req: &WmRequest| { + if req.headers.get("SessionId").map(|v| v.as_bytes()) == Some(b"BASE") { + ResponseTemplate::new(200).set_body_json(serde_json::json!({"issues": [], "numberOfPropagateIterations": 1, "updates": {}})) + } else { + ResponseTemplate::new(model_status).set_body_json(serde_json::json!({"issues": issues, "numberOfPropagateIterations": 4, "updates": {}})) + } + }) + .mount(&s).await; + Mock::given(method("GET")).and(path("/session/variables")) + .respond_with(move |req: &WmRequest| { + let library = serde_json::json!({"qualifiedName": "Base::things::multiplicity", "unit": "1", "value": "*..*"}); + let mut vars = vec![library]; + if req.headers.get("SessionId").map(|v| v.as_bytes()) == Some(b"USER") { + vars.extend(user_variables.as_array().unwrap().iter().cloned()); + } + ResponseTemplate::new(200).set_body_json(serde_json::json!({"variables": vars})) + }) + .mount(&s).await; + Mock::given(method("DELETE")) + .respond_with(ResponseTemplate::new(200)) + .mount(&s) + .await; + s +} + +#[tokio::test] +async fn sysmd_solve_returns_widened_ranges_with_units_and_hides_the_standard_library() { + let sidecar = sysmd_sidecar( + 200, + serde_json::json!([]), + serde_json::json!([{"qualifiedName": "w", "unit": "m", "value": "0.2..0.6"}, {"qualifiedName": "w/2/1", "unit": "1", "value": "*..*"}]), + ) + .await; + let app = test_app(state_with_sysmd("sysmd-ok", &sidecar.uri())); + let (s, b) = post_text( + app, + "/sysmd/solve", + b"attribute w: ISQ::LengthValue = 0.1 .. 0.3 [m]; ".to_vec(), + ) + .await; + assert_eq!(s, StatusCode::OK, "{b}"); + assert_eq!(b["verdict"], "consistent"); + let vars = b["variables"].as_array().unwrap(); + assert_eq!( + vars.len(), + 1, + "library and bookkeeping variables are not the caller's: {b}" + ); + assert_eq!(vars[0]["path"], "w"); + assert_eq!(vars[0]["range"]["kind"], "bounded"); + let (lo, hi) = ( + vars[0]["range"]["range"]["lo"].as_f64().unwrap(), + vars[0]["range"]["range"]["hi"].as_f64().unwrap(), + ); + assert!( + lo < 0.2 && 0.6 < hi, + "printed bounds are widened outward: [{lo}, {hi}]" + ); + assert_eq!(vars[0]["quantity"]["unit"]["dimension"]["length"], 1); +} + +#[tokio::test] +async fn sysmd_solve_reports_an_unsolvable_model_as_a_verdict_not_an_http_error() { + let issues = + serde_json::json!([{"kind": "ERROR_SYNTACTICAL", "message": "expected ']'", "line": 1}]); + let sidecar = sysmd_sidecar(500, issues, serde_json::json!([])).await; + let (s, b) = post_text( + test_app(state_with_sysmd("sysmd-err", &sidecar.uri())), + "/sysmd/solve?language=kerml", + b"part x {{{".to_vec(), + ) + .await; + assert_eq!(s, StatusCode::OK, "{b}"); + assert_eq!(b["verdict"], "error"); + assert_eq!(b["issues"][0]["kind"], "ERROR_SYNTACTICAL"); +} + +#[tokio::test] +async fn sysmd_solve_refuses_bad_requests_and_reports_a_missing_or_down_sidecar() { + let (s, b) = post_text( + test_app(test_state("sysmd-none")), + "/sysmd/solve", + b"x".to_vec(), + ) + .await; + assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(b["error"].as_str(), Some("sysmd_not_configured")); + + let sidecar = sysmd_sidecar(200, serde_json::json!([]), serde_json::json!([])).await; + let (s, _) = post_text( + test_app(state_with_sysmd("sysmd-lang", &sidecar.uri())), + "/sysmd/solve?language=cobol", + b"x".to_vec(), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let (s, _) = post_text( + test_app(state_with_sysmd("sysmd-utf", &sidecar.uri())), + "/sysmd/solve", + vec![0xff, 0xfe], + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let (s, _) = post_text( + test_app(state_with_sysmd("sysmd-big", &sidecar.uri())), + "/sysmd/solve", + vec![b'a'; 300 * 1024], + ) + .await; + assert_eq!(s, StatusCode::PAYLOAD_TOO_LARGE); + + let (s, b) = post_text( + test_app(state_with_sysmd("sysmd-down", "http://127.0.0.1:1")), + "/sysmd/solve", + b"x".to_vec(), + ) + .await; + assert_eq!(s, StatusCode::BAD_GATEWAY); + assert_eq!(b["error"].as_str(), Some("sysmd_unavailable")); +} + +#[tokio::test] +async fn health_reports_the_sysmd_sidecar_only_when_configured() { + let sidecar = sysmd_sidecar(200, serde_json::json!([]), serde_json::json!([])).await; + let (_, with) = get_json( + test_app(state_with_sysmd("sysmd-health", &sidecar.uri())), + "/health", + ) + .await; + assert_eq!(with["checks"]["sysmd"]["ok"], true); + let (_, down) = get_json( + test_app(state_with_sysmd("sysmd-health-down", "http://127.0.0.1:1")), + "/health", + ) + .await; + assert_eq!(down["checks"]["sysmd"]["ok"], false); + let (_, without) = get_json(test_app(test_state("sysmd-nohealth")), "/health").await; + assert!(without["checks"]["sysmd"].is_null()); +} + +#[tokio::test] +async fn mcp_manifest_advertises_solve_constraints_bound_to_the_sysmd_route() { + let (_, v) = get_json(test_app(test_state("mcp-sysmd")), "/mcp/tools").await; + let t = v + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == "solve_constraints") + .expect("solve_constraints missing"); + assert_eq!(t["httpBinding"]["pathTemplate"], "/sysmd/solve"); + assert_eq!(t["httpBinding"]["args"][0]["placement"], "body"); + assert_eq!(t["httpBinding"]["args"][1]["name"], "language"); + assert_eq!(t["httpBinding"]["args"][1]["placement"], "query"); +} + #[tokio::test] async fn mcp_manifest_advertises_the_sysml_tools_bound_to_the_sysml_routes() { let (_, v) = get_json(test_app(test_state("mcp-sysml")), "/mcp/tools").await; diff --git a/crates/kr0ki-server/tests/kubediagram_live.rs b/crates/kr0ki-server/tests/kubediagram_live.rs index 5ea509f..8367d37 100644 --- a/crates/kr0ki-server/tests/kubediagram_live.rs +++ b/crates/kr0ki-server/tests/kubediagram_live.rs @@ -51,6 +51,7 @@ async fn kr0ki_renders_its_own_deployment_manifest() { ui_bus: Arc::new(kr0ki_core::ui_bus::UiBus::new()), brand_dir: std::env::temp_dir().join("kr0ki-no-brands"), sysml_mcp: None, + sysmd: None, storyb00k_agent_url: None, llm_api_url: None, llm_api_key: None, diff --git a/deploy/systemd/kr0ki-sysmd.service b/deploy/systemd/kr0ki-sysmd.service new file mode 100644 index 0000000..f1ac0cf --- /dev/null +++ b/deploy/systemd/kr0ki-sysmd.service @@ -0,0 +1,14 @@ +[Unit] +Description=kr0ki: SysMD constraint-solver sidecar (:8081, loopback only) +After=network-online.target + +[Service] +ExecStartPre=-/usr/bin/podman rm -f kr0ki-sysmd +# SysMD has no way to bound its own sessions, so the memory limit + Restart are the backstop; kr0ki deletes each session it opens. +ExecStart=/usr/bin/podman run --rm --name kr0ki-sysmd --init --memory=1g --memory-swap=1g --read-only --tmpfs /tmp -v kr0ki-sysmd-data:/app/SysMD:U --cap-drop=ALL --security-opt no-new-privileges -p 127.0.0.1:8081:8081 localhost/kr0ki-sysmd:dev +ExecStop=/usr/bin/podman stop -t 10 kr0ki-sysmd +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/docs/SYSMD-SIDECAR.md b/docs/SYSMD-SIDECAR.md new file mode 100644 index 0000000..9e14ee5 --- /dev/null +++ b/docs/SYSMD-SIDECAR.md @@ -0,0 +1,47 @@ +# SysMD sidecar + +[tukcps/SysMD](https://github.com/tukcps/SysMD) is an interval constraint solver for SysML v2 / KerML (AADD-based; Kotlin, JVM, Spring Boot). +kr0ki runs it as an **optional black-box sidecar**: it sends model text over REST and reads back solved variables. No SysMD code is linked, and +nothing in kr0ki depends on it being there (`/sysmd/solve` answers 503 `sysmd_not_configured` without `KR0KI_SYSMD_URL`). + +```bash +just sysmd-image # clone PromptExecution/SysMD@kr0ki/sidecar, gradle bootJar (JDK 25, ~7 min, run ALONE), podman build +just sysmd-up # 127.0.0.1:8081, read-only root, 1 GiB, cap-drop ALL +just test-live-sysmd # the solver client against the running sidecar +KR0KI_SYSMD_URL=http://127.0.0.1:8081 just run +curl -X POST 'localhost:8787/sysmd/solve?language=sysml' --data-binary \ + 'attribute l: ISQ::LengthValue = 0.1 .. 0.3 [m]; attribute w: ISQ::LengthValue = l * 2.0;' +``` + +## What comes back +`SolveReport { verdict, issues, iterations, variables[] }`. Each variable has SysMD's own `raw` string, a parsed `range`, and (when the range is +bounded and the unit maps) a `ufo_types::quantity::Quantity`. + +* `verdict`: `consistent`; `inconsistent` (a `WARN_INCONSISTENCY`: a constraint or dependency cannot hold, so the values are **not** a solution); + `error` (an `ERROR*` issue: the model did not parse or analyse). A model SysMD rejects is a report, not an HTTP error. +* `range.kind`: `bounded` | `unbounded` | `empty` | `not_a_number` | `other` (`true`, `Unknown`, dates). An unbounded or empty range is never a number. +* The standard library SysMD loads into every session, and its `a/2/1` bookkeeping variables, are not returned. + +## Why the numbers are widened (soundness) +SysMD prints values for people (`Representer`, 5 decimals of the mantissa), so the string loses information. Found in `Representer.kt` and +confirmed live on 4.3.0 (2026-10-05): `2.000001` prints `2`; `0..1e-7` prints `0` (a range whose bounds are within 1e-5, or both under 5e-6, prints +only its lower bound); `1..1e9` prints `0..1e9`. `sysmd_client::parse_value` therefore **widens every printed bound outward** so the interval +contains the true range. Cost: an exact `6` comes back as roughly `[5.99997, 6.00003]`. That is the honest precision of what SysMD reports. +Values are always in SI base units (`5 mm` -> `0.005 m`, `20 degC` -> `293.15 K`); units are mapped by dimension (`kg m / s^2`, `1 / s`, `m^2`) and an +unmappable unit is reported (`unit_problem`), not guessed. + +## Our patches to SysMD (fork `PromptExecution/SysMD`, branch `kr0ki/sidecar`) +| Branch | Fix | +|---|---| +| `fix/toolchain-resolver` | `jvmToolchain(25)` could not be provisioned on a JRE-only machine | +| `fix/currency-distinct-units` | all currencies were canonicalised to EUR, so `5 USD + 5 EUR` summed | +| `fix/rest-session-libraries` | `POST /session` made a session with **no standard library**, so every model failed with `NullPointerException` (the Notebook UI passes the libraries; REST did not). Has a regression test that fails without the fix | +| `feat/rest-delete-session` | no way to release a REST session (documented in the controller, never implemented); each holds the whole library in memory | + +Upstream is on the CPS chair's GitLab (GitHub is a mirror), so patches go by emailed diff; the formatted patches are kept with the fork branches. + +## Limits +* SysMD writes each project as files under `/app/SysMD`: a named podman volume (`kr0ki-sysmd-data`) mounted with `:U`, so podman chowns it to the container user (uid 10001) and no world-writable mode is needed. (A tmpfs cannot take `uid=` under rootless podman.) kr0ki deletes each project it creates; `podman volume rm kr0ki-sysmd-data` clears the rest. +* `POST /session` / `PUT /session/model` take no auth: keep it on loopback (the unit and `just sysmd-up` bind `127.0.0.1`). +* kr0ki solves at most 2 models at once and deletes each project and session; the container memory limit and `Restart=on-failure` are the backstop. +* SysMD's own `Variable` values are only the *solved range*; kr0ki does not feed solutions back into `ufo-types` models yet. diff --git a/docs/assurance/BRIEF-assurance-thread.md b/docs/assurance/BRIEF-assurance-thread.md index 231c11e..d5b7de7 100644 --- a/docs/assurance/BRIEF-assurance-thread.md +++ b/docs/assurance/BRIEF-assurance-thread.md @@ -47,6 +47,17 @@ binding obligations stay distinguishable (`source_kind`). A **satisfaction relationship is an architectural assertion**; a **passing result is revision-specific evidence**. +### Evidence is revision-bound: re-verify on a minor release, not a patch + +Evidence records carry `model_revision` and `implementation_revision`, so a pass proves +only the revision it ran against; any new commit makes it stale for display. + +Release policy: run `just assurance-verify` (then `just assurance-view`) **when the +minor version bumps** (0.1.x -> 0.2.0) and the evidence is promoted for that release. A +patch bump (0.1.1 -> 0.1.2) does not trigger a new assurance run; it keeps the evidence +of the minor it belongs to. Re-run earlier only when a change touches a verified +requirement's code, model, or configuration digest. + ## Runtime parts `AgentWorkload`, `WorkloadIdentity`, `ToolGateway`, `ExecutionSandbox`, `ModelService`, diff --git a/justfile b/justfile index a33821e..7a092a1 100644 --- a/justfile +++ b/justfile @@ -475,7 +475,7 @@ status-agent port="8789": # Boot persistence: user-level systemd units (linger is enabled for this account, so they start at boot, not login). # Needs the images built (`just dev-kroki-up`, `just sysml-mcp-image`) and ./target/debug/kr0ki (`cargo build`). -services_units := "kr0ki-kroki kr0ki-sysml-mcp kr0ki-server kr0ki-agent" +services_units := "kr0ki-kroki kr0ki-sysml-mcp kr0ki-sysmd kr0ki-server kr0ki-agent" services-install: #!/usr/bin/env bash set -euo pipefail @@ -503,6 +503,21 @@ test-sysml-mcp: pnpm --dir containers/kr0ki-sysml-mcp install --frozen-lockfile pnpm --dir containers/kr0ki-sysml-mcp test +# SysMD constraint-solver sidecar (loopback :8081): builds the PromptExecution/SysMD fork (JDK 25; run alone, not beside cargo). +sysmd-image: + scripts/build-sysmd.sh + +sysmd-up: + -podman rm -f kr0ki-sysmd + podman run -d --rm --name kr0ki-sysmd --init --memory=1g --memory-swap=1g --read-only --tmpfs /tmp -v kr0ki-sysmd-data:/app/SysMD:U --cap-drop=ALL --security-opt no-new-privileges -p 127.0.0.1:8081:8081 localhost/kr0ki-sysmd:dev + @echo "SysMD on http://127.0.0.1:8081 (set KR0KI_SYSMD_URL to use it from kr0ki-server)" +sysmd-down: + -podman rm -f kr0ki-sysmd + +# Live test of the solver client against a running sidecar (`just sysmd-up` first; it takes ~10 s to start). +test-live-sysmd: + KR0KI_SYSMD_URL=${KR0KI_SYSMD_URL:-http://127.0.0.1:8081} cargo test -p kr0ki-core --test sysmd_live -- --ignored --test-threads=1 + # SysML v2 pilot API server + postgres (loopback :9000). Build it once with ~/.local/share/kr0ki/sysml-api/build.sh. sysml-api-up: scripts/sysml-api-up.sh diff --git a/playbook/src/App.assurance.test.js b/playbook/src/App.assurance.test.js new file mode 100644 index 0000000..1a58246 --- /dev/null +++ b/playbook/src/App.assurance.test.js @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { Quasar } from 'quasar' +import App from './App.vue' + +const catalog = { useCases: ['process flow'], types: [{ id: 'flowchart', name: 'Flowchart', syntax: 'd2', exampleId: 'e1', useCases: ['process flow'], blurb: 'Steps.', samplePrompt: 'p' }] } +const examples = [{ id: 'e1', format: 'd2', title: 'Flow', description: 'd', input_kind: 'code', source: 'a -> b', outputs: ['svg'] }] +const StoryStub = { name: 'StoryB00k', props: { planner: Boolean, threadId: String }, template: '
' } +// The real view is covered by AssuranceView.test.js; here we only check what App hands it. +const AssuranceStub = { + name: 'AssuranceView', + props: { rendererUrl: String, token: String }, + template: '
', +} + +beforeEach(() => { + sessionStorage.clear() + localStorage.clear() + Element.prototype.scrollIntoView = vi.fn() + vi.stubGlobal('CSS', { escape: (s) => s }) + vi.stubGlobal('fetch', vi.fn(async (url) => { + const u = String(url) + if (u.includes('/api/catalog')) return { ok: true, json: async () => catalog } + if (u.includes('examples')) return { ok: true, json: async () => examples } + return { ok: false, status: 404, json: async () => ({}), text: async () => '' } + })) +}) +afterEach(() => { vi.unstubAllGlobals(); document.body.replaceChildren() }) + +async function mountApp() { + const w = mount(App, { global: { plugins: [[Quasar, {}]], stubs: { StoryB00k: StoryStub, AssuranceView: AssuranceStub } }, attachTo: document.body }) + await flushPromises() + return w +} +const t = (w, id) => w.find(`[data-testid="${id}"]`) + +describe('the Assurance tab', () => { + it('is in the menu and opens the assurance view with the renderer url', async () => { + localStorage.setItem('kr0ki:rendererUrl', 'http://k.test:8787') + const w = await mountApp() + expect(t(w, 'tab-assurance').text()).toBe('Assurance') + expect(w.find('.assurance-stub').exists()).toBe(false) + await t(w, 'tab-assurance').trigger('click') + const stub = w.find('.assurance-stub') + expect(stub.exists()).toBe(true) + expect(stub.attributes('data-url')).toBe('http://k.test:8787') + expect(t(w, 'tab-assurance').classes()).toContain('active') + }) + + it('hides the gallery hero while the assurance view is open', async () => { + const w = await mountApp() + expect(w.find('.hero').exists()).toBe(true) + await t(w, 'tab-assurance').trigger('click') + expect(w.find('.hero').exists()).toBe(false) + }) + + it('leaves no assurance view behind when the person goes elsewhere', async () => { + const w = await mountApp() + await t(w, 'tab-assurance').trigger('click') + await t(w, 'tab-gallery').trigger('click') + expect(w.find('.assurance-stub').exists()).toBe(false) + }) +}) + +describe('the access token', () => { + it('starts from the session, not from persistent storage', async () => { + sessionStorage.setItem('kr0ki:token', 'tok-from-session') + const w = await mountApp() + await t(w, 'tab-assurance').trigger('click') + expect(w.find('.assurance-stub').attributes('data-token')).toBe('tok-from-session') + }) + + it('ignores a token left in localStorage (it must never persist)', async () => { + localStorage.setItem('kr0ki:token', 'tok-in-local-storage') + const w = await mountApp() + await t(w, 'tab-assurance').trigger('click') + expect(w.find('.assurance-stub').attributes('data-token')).toBe('') + }) + + it('saving it in Setup keeps it in sessionStorage only and hands it to the view', async () => { + const w = await mountApp() + await t(w, 'tab-setup').trigger('click') + const input = t(w, 'kr0ki-token') + expect(input.attributes('type')).toBe('password') + await input.setValue(' tok-new-secret ') + await w.find('form.setup__form').trigger('submit') + await flushPromises() + expect(sessionStorage.getItem('kr0ki:token')).toBe('tok-new-secret') + expect(JSON.stringify({ ...localStorage })).not.toContain('tok-new-secret') + await t(w, 'tab-assurance').trigger('click') + expect(w.find('.assurance-stub').attributes('data-token')).toBe('tok-new-secret') + }) + + it('clearing it removes it from the session', async () => { + sessionStorage.setItem('kr0ki:token', 'tok-old') + const w = await mountApp() + await t(w, 'tab-setup').trigger('click') + await t(w, 'kr0ki-token').setValue('') + await w.find('form.setup__form').trigger('submit') + await flushPromises() + expect(sessionStorage.getItem('kr0ki:token')).toBeNull() + }) + + it('can be revealed and hidden while typing', async () => { + const w = await mountApp() + await t(w, 'tab-setup').trigger('click') + expect(t(w, 'kr0ki-token').attributes('type')).toBe('password') + await t(w, 'toggle-token').trigger('click') + expect(t(w, 'kr0ki-token').attributes('type')).toBe('text') + await t(w, 'toggle-token').trigger('click') + expect(t(w, 'kr0ki-token').attributes('type')).toBe('password') + }) + + it('tells the person it is session-only', async () => { + const w = await mountApp() + await t(w, 'tab-setup').trigger('click') + expect(w.text()).toContain('this browser session only') + }) +}) diff --git a/playbook/src/App.test.js b/playbook/src/App.test.js index f5ed0bd..1129407 100644 --- a/playbook/src/App.test.js +++ b/playbook/src/App.test.js @@ -36,7 +36,7 @@ describe('App navigation', () => { it('has Planner and Projects as top-level menu items after Gallery; the gallery is home', async () => { const w = await mountApp() const labels = w.findAll('.view-tab').map((b) => b.text()) - expect(labels).toEqual(['Gallery', 'Planner', 'Projects', 'Code Editor', 'Agent', 'Setup']) + expect(labels).toEqual(['Gallery', 'Planner', 'Projects', 'Assurance', 'Code Editor', 'Agent', 'Setup']) expect(w.find('[data-testid="tab-gallery"]').classes()).toContain('active') expect(w.find('.gallery').exists()).toBe(true) w.unmount() diff --git a/playbook/src/App.vue b/playbook/src/App.vue index 3d394da..b7eb9a6 100644 --- a/playbook/src/App.vue +++ b/playbook/src/App.vue @@ -7,6 +7,7 @@ import Setup from './components/Setup.vue' import CatalogTree from './components/CatalogTree.vue' import PlannerView from './components/PlannerView.vue' import ProjectsView from './components/ProjectsView.vue' +import AssuranceView from './components/AssuranceView.vue' import { connectUiBridge, plannerSessionId } from './lib/uiBridge.js' import { loadLspUrls, saveLspUrls } from './lib/lsp.js' @@ -44,6 +45,20 @@ const llmUrl = ref(localStorage.getItem('kr0ki:llmUrl') || `http://${window.loca const llmKey = ref(localStorage.getItem('kr0ki:llmKey') || '') const llmModel = ref(localStorage.getItem('kr0ki:llmModel') || 'gpt-4o') +// Access token for a server with per-identity grants. SESSION storage on purpose: it is cleared when the tab closes and is never +// written next to the persistent settings. It is only ever sent as an Authorization header (see lib/assurance.js). +const kr0kiToken = ref(readSession('kr0ki:token')) +function readSession(key) { + try { return sessionStorage.getItem(key) || '' } catch { return '' } +} +function updateKr0kiToken(token) { + kr0kiToken.value = token + try { + if (token) sessionStorage.setItem('kr0ki:token', token) + else sessionStorage.removeItem('kr0ki:token') + } catch { /* storage unavailable: the token still works for this page load */ } +} + function updateRendererUrl(url) { rendererUrl.value = url localStorage.setItem('kr0ki:rendererUrl', url) @@ -267,6 +282,9 @@ onMounted(async () => { + @@ -288,7 +306,7 @@ onMounted(async () => {
-
+

IAC / CODE → PROCEDURAL DIAGRAM → KROKI → SVG / PNG

Diagram-as-code, rendered.

@@ -319,8 +337,10 @@ onMounted(async () => { { @update:output-format="updateOutputFormat" /> + structuredClone(x) +const refusal = (status, error, message) => new AssuranceError(status, { error, message }) + +/** A fake API client whose answers are the real fixtures; override any method. */ +function fakeClient(over = {}) { + return { + list: vi.fn(async (f = {}) => { + const l = clone(list) + const rows = l.requirements.filter((r) => (!f.state || r.assurance === f.state) && (!f.owner || r.owner === f.owner) && (!f.status || r.status === f.status) && (!f.gap || r.gaps.some((g) => g.gap === f.gap))) + return { ...l, total: rows.length, requirements: rows } + }), + requirement: vi.fn(async () => clone(detailA06)), + trace: vi.fn(async () => clone(traceA06)), + evidence: vi.fn(async () => clone(evidenceA06)), + viewSvg: vi.fn(async () => new Blob([''], { type: 'image/svg+xml' })), + audit: vi.fn(async () => clone(audit)), + verify: vi.fn(async (caseId) => ({ revisions: {}, case_id: caseId, result: 'pass', detail: '9 tests passed', evidence_keys: [] })), + ...over, + } +} +async function mountView(client = fakeClient(), props = {}) { + const w = mount(AssuranceView, { props: { client, rendererUrl: 'http://k.test:8787', ...props }, attachTo: document.body }) + await flushPromises() + return w +} +const t = (w, id) => w.find(`[data-testid="${id}"]`) + +beforeEach(() => { + globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake-diagram') + globalThis.URL.revokeObjectURL = vi.fn() +}) +afterEach(() => document.body.replaceChildren()) + +describe('the requirements table', () => { + it('shows the revisions the answer was computed at, shortened', async () => { + const w = await mountView() + const revs = t(w, 'revisions').text() + expect(revs).toContain('0213bc9c3286') + expect(revs).toContain(shortRev(list.revisions.implementation_revision)) + expect(revs).toContain('abb46f1005+dirty.') // the dirty marker stays visible: a dirty tree is a different revision + }) + + it('lists every requirement with its state as a label and a symbol, not colour alone', async () => { + const w = await mountView() + expect(w.findAll('tbody tr')).toHaveLength(8) + const s = t(w, 'state-KR-A06') + expect(s.text()).toContain('result stale') + expect(s.text()).toContain('⟳') + expect(s.classes()).toContain('st-stale') + expect(t(w, 'count').text()).toContain('8 of 8') + }) + + it('counts requirements per state in the chips', async () => { + const w = await mountView() + expect(t(w, 'chip-stale').text()).toContain('8') + expect(t(w, 'chip-verified').text()).toContain('0') + expect(w.findAll('[data-testid^="chip-"]')).toHaveLength(5) + }) + + it('keeps "satisfied · untested" and "verified" visibly different', async () => { + const mixed = clone(list) + mixed.requirements[0].assurance = 'verified' + mixed.requirements[1].assurance = 'satisfied_untested' + const w = await mountView(fakeClient({ list: vi.fn(async () => mixed) })) + const a = t(w, 'state-KR-A01') + const b = t(w, 'state-KR-A02') + expect(a.text()).not.toBe(b.text()) + expect(a.text()).toContain('✓ verified') + expect(b.text()).toContain('satisfied · untested') + expect(a.classes()).not.toEqual(b.classes()) + }) + + it('shows the source kind so binding obligations, policy and guidance stay distinguishable', async () => { + const w = await mountView() + expect(t(w, 'row-KR-A06').text()).toContain('policy') + }) + + it('lists each requirement\'s gaps', async () => { + const w = await mountView() + expect(t(w, 'row-KR-A01').find('.gaps').text()).toContain('stale') + }) +}) + +describe('filters are applied by the server, so only what is needed is loaded', () => { + it('a state chip asks the server for that state only', async () => { + const c = fakeClient() + const w = await mountView(c) + await t(w, 'chip-verified').trigger('click') + await flushPromises() + expect(c.list).toHaveBeenLastCalledWith({ state: 'verified', status: '', owner: '', gap: '' }) + expect(t(w, 'empty').exists()).toBe(true) + expect(t(w, 'count').text()).toContain('0 of 8') + expect(t(w, 'count').text()).toContain('filtered on the server') + expect(t(w, 'chip-verified').attributes('aria-pressed')).toBe('true') + }) + + it('clicking the active chip again clears it', async () => { + const c = fakeClient() + const w = await mountView(c) + await t(w, 'chip-stale').trigger('click') + await flushPromises() + expect(w.findAll('tbody tr')).toHaveLength(8) + await t(w, 'chip-stale').trigger('click') + await flushPromises() + expect(t(w, 'chip-stale').attributes('aria-pressed')).toBe('false') + expect(t(w, 'count').text()).not.toContain('filtered') + }) + + it('offers only values that exist, and filters by them', async () => { + const c = fakeClient() + const w = await mountView(c) + const owners = t(w, 'filter-owner').findAll('option').map((o) => o.text()) + expect(owners).toEqual(['any', 'kr0ki-maintainers']) + await t(w, 'filter-gap').setValue('stale') + await flushPromises() + expect(c.list).toHaveBeenLastCalledWith(expect.objectContaining({ gap: 'stale' })) + await t(w, 'clear-filters').trigger('click') + await flushPromises() + expect(t(w, 'clear-filters').attributes('disabled')).toBeDefined() + }) +}) + +describe('the detail drawer shows the whole thread', () => { + async function open() { + const c = fakeClient() + const w = await mountView(c) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + return { w, c } + } + + it('loads the requirement, its trace and its evidence', async () => { + const { c } = await open() + expect(c.requirement).toHaveBeenCalledWith('KR-A06') + expect(c.trace).toHaveBeenCalledWith('KR-A06') + expect(c.evidence).toHaveBeenCalledWith({ requirement: 'KR-A06' }) + }) + + it('shows the statement, source obligations with their kind, element, control and case', async () => { + const { w } = await open() + expect(t(w, 'statement').text()).toContain('tool gateway shall deny') + const obligations = t(w, 'obligations').text() + expect(obligations).toContain('policy') + expect(obligations).toContain('guidance') // the MCP security text + expect(t(w, 'elements').text()).toContain('KrOKiAssurance::ToolGateway') + expect(t(w, 'controls').text()).toContain('CTL-A06') + expect(t(w, 'controls').text()).toContain('crates/kr0ki-server/src/gateway.rs') + expect(t(w, 'cases').text()).toContain('VC-A06') + expect(t(w, 'cases').text()).toContain('cargo test -p kr0ki-server --test assurance_a06') + }) + + it('shows evidence as revision-bound and says which revision moved when it is stale', async () => { + const { w } = await open() + const ev = t(w, 'evidence').text() + expect(ev).toContain('pass') + expect(ev).toContain('stale') + expect(ev).toContain('implementation changed') + expect(ev).toContain('artifact intact') + expect(t(w, 'stale-VC-A06').exists()).toBe(true) + }) + + it('shows links as resolved at the current model revision', async () => { + const { w } = await open() + expect(t(w, 'links').text()).toContain('KrOKiAssurance::ToolGateway') + expect(t(w, 'links').text()).toContain('resolved') + }) + + it('flags a control with no recorded implementation as a gap, not a blank', async () => { + const d = clone(detailA06) + d.controls[0].implementation = '' + const w = await mountView(fakeClient({ requirement: vi.fn(async () => d) })) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + expect(t(w, 'no-impl').text()).toContain('implementation gap') + }) + + it('says plainly when nothing has ever been run', async () => { + const d = clone(detailA06) + d.thread.evidence = [] + d.thread.assurance = 'satisfied_untested' + const w = await mountView(fakeClient({ requirement: vi.fn(async () => d) })) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + expect(t(w, 'no-evidence').text()).toContain('never been run') + expect(t(w, 'detail-state').text()).toContain('satisfied · untested') + }) + + it('reports dangling links with the reason', async () => { + const tr = clone(traceA06) + tr.links[0] = { ...tr.links[0], status: 'dangling', reason: '`X` does not exist at model revision sha256:aa' } + const w = await mountView(fakeClient({ trace: vi.fn(async () => tr) })) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + expect(t(w, 'links').text()).toContain('dangling') + expect(t(w, 'links').text()).toContain('does not exist at model revision') + }) + + it('shows statement lint findings', async () => { + const d = clone(detailA06) + d.statement_lints = ['vague or open term `TBD`'] + const w = await mountView(fakeClient({ requirement: vi.fn(async () => d) })) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + expect(t(w, 'lints').text()).toContain('TBD') + }) + + it('closes, and a failing detail load does not break the list', async () => { + const c = fakeClient({ requirement: vi.fn(async () => { throw refusal(404, 'not_found', 'unknown requirement `KR-A06`') }) }) + const w = await mountView(c) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + expect(t(w, 'detail-error').exists()).toBe(true) + expect(w.findAll('tbody tr')).toHaveLength(8) + await t(w, 'close-detail').trigger('click') + expect(t(w, 'detail').exists()).toBe(false) + }) + + it('discards a slow answer for a requirement the person has already moved away from', async () => { + let releaseFirst + const slow = new Promise((r) => { releaseFirst = r }) + const first = clone(detailA06) + const second = clone(detailA06) + second.requirement.id = 'KR-A01' + second.requirement.statement = 'The requirement adapter shall preserve identifiers.' + const requirement = vi.fn((id) => (id === 'KR-A06' ? slow.then(() => first) : Promise.resolve(second))) + const w = await mountView(fakeClient({ requirement })) + await t(w, 'open-KR-A06').trigger('click') + await t(w, 'open-KR-A01').trigger('click') + await flushPromises() + releaseFirst() + await flushPromises() + expect(t(w, 'statement').text()).toContain('requirement adapter') + }) +}) + +describe('running a case is explicit', () => { + async function open(client = fakeClient()) { + const w = await mountView(client) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + return w + } + + it('asks first, naming what will run and where, and runs nothing until confirmed', async () => { + const c = fakeClient() + const w = await open(c) + await t(w, 'run-VC-A06').trigger('click') + expect(c.verify).not.toHaveBeenCalled() + expect(t(w, 'cases').text()).toContain('runs the command above on the server') + expect(t(w, 'confirm-run-VC-A06').exists()).toBe(true) + }) + + it('cancel runs nothing', async () => { + const c = fakeClient() + const w = await open(c) + await t(w, 'run-VC-A06').trigger('click') + await w.findAll('button').find((b) => b.text() === 'Cancel').trigger('click') + expect(c.verify).not.toHaveBeenCalled() + expect(t(w, 'run-VC-A06').exists()).toBe(true) + }) + + it('confirming runs the case at the implementation revision the view is showing, then refreshes', async () => { + const c = fakeClient() + const w = await open(c) + const listCalls = c.list.mock.calls.length + await t(w, 'run-VC-A06').trigger('click') + await t(w, 'confirm-run-VC-A06').trigger('click') + await flushPromises() + expect(c.verify).toHaveBeenCalledWith('VC-A06', detailA06.revisions.implementation_revision) + expect(t(w, 'run-notice').text()).toContain('VC-A06: pass') + expect(t(w, 'run-notice').classes()).toContain('ok') + expect(c.list.mock.calls.length).toBeGreaterThan(listCalls) // the view re-read the server + }) + + it('a failing case is shown as a failure, not a success', async () => { + const c = fakeClient({ verify: vi.fn(async () => ({ case_id: 'VC-A06', result: 'fail', detail: '2 tests failed, 5 passed' })) }) + const w = await open(c) + await t(w, 'run-VC-A06').trigger('click') + await t(w, 'confirm-run-VC-A06').trigger('click') + await flushPromises() + expect(t(w, 'run-notice').classes()).toContain('bad') + expect(t(w, 'run-notice').text()).toContain('fail') + }) + + it('a refusal (the repository moved on) is explained and nothing is claimed', async () => { + const c = fakeClient({ verify: vi.fn(async () => { throw refusal(409, 'revision_mismatch', 'you asked to verify X, the repository is at Y') }) }) + const w = await open(c) + await t(w, 'run-VC-A06').trigger('click') + await t(w, 'confirm-run-VC-A06').trigger('click') + await flushPromises() + expect(t(w, 'run-notice').text()).toContain('moved on') + expect(t(w, 'run-notice').classes()).toContain('bad') + }) + + it('a missing grant is explained', async () => { + const c = fakeClient({ verify: vi.fn(async () => { throw refusal(403, 'forbidden', 'identity `reader` has no `assurance.verify` grant') }) }) + const w = await open(c) + await t(w, 'run-VC-A06').trigger('click') + await t(w, 'confirm-run-VC-A06').trigger('click') + await flushPromises() + expect(t(w, 'run-notice').text()).toContain('assurance.verify') + }) +}) + +describe('errors are explained, not swallowed', () => { + const listFails = (e) => fakeClient({ list: vi.fn(async () => { throw e }) }) + + it('401: tells the person to add a token under Setup', async () => { + const w = await mountView(listFails(refusal(401, 'unauthorized', 'missing or invalid bearer token'))) + expect(t(w, 'error').text()).toContain('access token') + expect(t(w, 'error').text()).toContain('Setup') + expect(w.findAll('tbody tr')).toHaveLength(0) + }) + + it('403: names the grant that is missing', async () => { + const w = await mountView(listFails(refusal(403, 'forbidden', 'identity `x` has no `assurance.read` grant'))) + expect(t(w, 'error').text()).toContain('assurance.read') + }) + + it('503: says the assurance thread is not enabled on this server', async () => { + const w = await mountView(listFails(refusal(503, 'assurance_not_configured', 'x'))) + expect(t(w, 'error').text()).toContain('not enabled') + }) + + it('a network failure is reported as such', async () => { + const w = await mountView(listFails(new TypeError('Failed to fetch'))) + expect(t(w, 'error').text()).toContain('Could not reach') + }) + + it('the error is announced to assistive technology', async () => { + const w = await mountView(listFails(refusal(500, 'x', 'boom'))) + expect(t(w, 'error').attributes('role')).toBe('alert') + }) +}) + +describe('the diagram', () => { + it('renders lazily, only when the tab is opened', async () => { + const c = fakeClient() + const w = await mountView(c) + expect(c.viewSvg).not.toHaveBeenCalled() + await t(w, 'a-tab-diagram').trigger('click') + await flushPromises() + expect(c.viewSvg).toHaveBeenCalledTimes(1) + expect(t(w, 'diagram-img').attributes('src')).toBe('blob:fake-diagram') + expect(t(w, 'diagram-img').attributes('alt')).toContain('Assurance thread diagram') + }) + + it('explains the two claims in its legend', async () => { + const w = await mountView() + await t(w, 'a-tab-diagram').trigger('click') + await flushPromises() + const legend = t(w, 'diagram-panel').text() + expect(legend).toContain('satisfaction assertion') + expect(legend).toContain('evidence') + }) + + it('shows a render failure instead of an empty box', async () => { + const w = await mountView(fakeClient({ viewSvg: vi.fn(async () => { throw refusal(502, 'render_failed', 'backend down') }) })) + await t(w, 'a-tab-diagram').trigger('click') + await flushPromises() + expect(t(w, 'diagram-error').text()).toContain('backend down') + expect(t(w, 'diagram-img').exists()).toBe(false) + }) + + it('can be zoomed inside a scrolling frame, and opened at full size', async () => { + const w = await mountView() + await t(w, 'a-tab-diagram').trigger('click') + await flushPromises() + expect(t(w, 'diagram-img').attributes('style')).toContain('width: 100%') + expect(t(w, 'zoom-100').attributes('aria-pressed')).toBe('true') + await t(w, 'zoom-300').trigger('click') + expect(t(w, 'diagram-img').attributes('style')).toContain('width: 300%') + expect(t(w, 'zoom-300').attributes('aria-pressed')).toBe('true') + expect(t(w, 'zoom-100').attributes('aria-pressed')).toBe('false') + expect(w.find('.frame').attributes('tabindex')).toBe('0') // keyboard-scrollable + expect(t(w, 'open-full').attributes('href')).toBe('blob:fake-diagram') + expect(t(w, 'open-full').attributes('rel')).toBe('noopener') + }) + + it('releases the object URL on unmount', async () => { + const w = await mountView() + await t(w, 'a-tab-diagram').trigger('click') + await flushPromises() + w.unmount() + expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:fake-diagram') + }) +}) + +describe('the audit panel', () => { + async function openAudit(client = fakeClient()) { + const w = await mountView(client) + await t(w, 'a-tab-audit').trigger('click') + await flushPromises() + return w + } + + it('shows the records and says the chain verifies', async () => { + const c = fakeClient() + const w = await openAudit(c) + expect(c.audit).toHaveBeenCalledWith({ decision: '', caller: '', phase: 'decision' }) + expect(t(w, 'chain').text()).toContain('Hash chain verifies') + expect(t(w, 'chain').text()).toContain('17 record(s)') + expect(w.findAll('[data-testid="audit-table"] tbody tr').length).toBe(audit.records.length) + }) + + it('shows a denial as a denial, with the reason', async () => { + const w = await openAudit() + const text = t(w, 'audit-table').text() + expect(text).toContain('deny') + expect(text).toContain('model.commit') + }) + + it('says loudly when the chain is broken', async () => { + const broken = clone(audit) + broken.chain = { ok: false, line: 4, seq: 3, reason: 'prev_hash does not match the previous record' } + const w = await openAudit(fakeClient({ audit: vi.fn(async () => broken) })) + expect(t(w, 'chain').text()).toContain('BROKEN at line 4') + expect(t(w, 'chain').classes()).toContain('bad') + }) + + it('filters by decision on the server', async () => { + const c = fakeClient() + const w = await openAudit(c) + await t(w, 'audit-decision').setValue('deny') + await flushPromises() + expect(c.audit).toHaveBeenLastCalledWith({ decision: 'deny', caller: '', phase: 'decision' }) + }) + + it('explains that reading the audit log needs its own grant', async () => { + const w = await openAudit(fakeClient({ audit: vi.fn(async () => { throw refusal(403, 'forbidden', 'identity `reader` has no `audit.read` grant') }) })) + expect(t(w, 'audit-error').text()).toContain('audit.read') + expect(t(w, 'audit-table').exists()).toBe(false) + }) + + it('does not load the log until the tab is opened', async () => { + const c = fakeClient() + await mountView(c) + expect(c.audit).not.toHaveBeenCalled() + }) +}) + +describe('credentials', () => { + it('never renders the token anywhere in the page', async () => { + const w = await mountView(fakeClient(), { token: 'tok-super-secret' }) + await t(w, 'open-KR-A06').trigger('click') + await t(w, 'a-tab-audit').trigger('click') + await flushPromises() + expect(w.html()).not.toContain('tok-super-secret') + }) + + it('builds its own client from the renderer url and token when none is injected', async () => { + const calls = [] + globalThis.fetch = vi.fn(async (url, init) => { calls.push({ url, init }); return { ok: true, status: 200, json: async () => ({ ...list }) } }) + const w = mount(AssuranceView, { props: { rendererUrl: 'http://k.test:8787', token: 'tok-abc' }, attachTo: document.body }) + await flushPromises() + expect(calls[0].url).toBe('http://k.test:8787/assurance/requirements') + expect(calls[0].init.headers.Authorization).toBe('Bearer tok-abc') + expect(w.html()).not.toContain('tok-abc') + }) + + it('re-reads when the token changes', async () => { + const c = fakeClient() + const w = await mountView(c) + const before = c.list.mock.calls.length + await w.setProps({ token: 'tok-new' }) + await flushPromises() + expect(c.list.mock.calls.length).toBeGreaterThan(before) + }) +}) diff --git a/playbook/src/components/AssuranceView.vue b/playbook/src/components/AssuranceView.vue new file mode 100644 index 0000000..7a3a27b --- /dev/null +++ b/playbook/src/components/AssuranceView.vue @@ -0,0 +1,509 @@ + + + + + diff --git a/playbook/src/components/Setup.vue b/playbook/src/components/Setup.vue index 22543eb..25923cd 100644 --- a/playbook/src/components/Setup.vue +++ b/playbook/src/components/Setup.vue @@ -4,16 +4,20 @@ import { LANGUAGES, isWsUrl, lspFormats } from '../lib/lsp.js' const props = defineProps({ rendererUrl: { type: String, default: '' }, + // Optional bearer token for a kr0ki server that has per-identity grants on. Session-only: App keeps it in sessionStorage. + kr0kiToken: { type: String, default: '' }, agentUrl: { type: String, default: '' }, // Optional language servers: { format: ws:// URL } lspUrls: { type: Object, default: () => ({}) }, }) -const emit = defineEmits(['update:rendererUrl', 'update:llmUrl', 'update:llmKey', 'update:llmModel', 'update:agentUrl', 'update:outputFormat', 'update:lspUrls']) +const emit = defineEmits(['update:kr0kiToken', 'update:rendererUrl', 'update:llmUrl', 'update:llmKey', 'update:llmModel', 'update:agentUrl', 'update:outputFormat', 'update:lspUrls']) // Local state for form inputs const localRendererUrl = ref(props.rendererUrl) const localAgentUrl = ref(props.agentUrl) +const localToken = ref(props.kr0kiToken) +const showToken = ref(false) const llmUrl = ref('') const llmKey = ref('') const llmModel = ref('') @@ -56,6 +60,9 @@ import { watch } from 'vue' watch(() => props.rendererUrl, (newVal) => { localRendererUrl.value = newVal }) +watch(() => props.kr0kiToken, (newVal) => { + localToken.value = newVal +}) function saveSettings() { // Save LLM settings to localStorage @@ -67,6 +74,8 @@ function saveSettings() { // Emit updates emit('update:rendererUrl', localRendererUrl.value) + // Deliberately NOT written to localStorage with the other settings: the token lives for this browser session only. + emit('update:kr0kiToken', localToken.value.trim()) emit('update:llmUrl', llmUrl.value) emit('update:llmKey', llmKey.value) emit('update:llmModel', llmModel.value) @@ -232,6 +241,25 @@ async function testAgent() { /> The kr0ki server that renders diagrams. Defaults to the current host. +