Skip to content

Semantic-diff renderer framework

Status: Implemented (registry + recipe renderer) Issue: #287 Umbrella: #283 Scope: Plugin registry in the gateway UI that maps a CRD kind to a function producing structured, human-readable diff entries for the Promote / ChangeRequest preview.

Why

The GitOps for Automation Engineers explanation doc commits to one load-bearing UX promise:

The Promote preview does not show you raw YAML. It shows you the change in the language you already use — phase hold times, alarm setpoints, equipment requirements, recipe parameter ranges.

A raw YAML diff (holdSeconds: 1800 → 2700) violates that promise. The semantic-diff renderer is the bridge: at preview time it walks the before and after objects and emits structured entries (Mash Hold: hold time 30 min → 45 min) the engineer can review in their domain.

The framework is deliberately a small registry (one function per CRD kind), so adding new coverage is an isolated change and the long-tail of CRDs (#292) can be parcelled out per kind without churning load-bearing code.

Where it lives

File Purpose
internal/gateway/static/js/diff-renderer.js Registry + shared formatters (formatDuration, parseISA88Duration). DOM-free.
internal/gateway/static/js/diff-renderers/<kind>.js One file per kind. Auto-registers when loaded.
internal/gateway/static/js/diff-renderers/_procedural-helpers.js Shared helpers for the procedural-template family (PhaseTemplate, OperationTemplate, UnitProcedureTemplate). Underscore-prefixed to mark as internal-to-the-family.
test/js/diff-renderer.test.js Framework unit tests.
test/js/diff-renderer-recipe.test.js Recipe renderer fixtures.
test/js/diff-renderer-{phase,operation,unitprocedure}template.test.js Procedural-template renderer fixtures.

The renderer modules are loaded by <script src="…"> from the three sub-app entry HTMLs (system/, hmi/, data/index.html). Browser loading auto-registers the renderer via the if (typeof window !== 'undefined') guard at the bottom of each file. The same files run under node:test via a CommonJS shim so the units can be exercised without a browser harness.

Registry API

// diff-renderer.js exposes these on `window` and as CommonJS exports.
registerDiffRenderer(kind, fn);
// fn(beforeObj, afterObj, ctx) -> Array<DiffEntry>

const result = computeSemanticDiff(beforeObj, afterObj, ctx);
//   { kind: "MasterRecipe", entries: [...], fallback: false }

computeSemanticDiff looks up the renderer by afterObj.kind (or beforeObj.kind for deletes) and invokes it. When no renderer is registered the result carries fallback: true and an empty entries array. The UI is expected to fall back to the raw-YAML diff toggle in that case.

Entry shape

Every renderer returns an array of DiffEntry:

interface DiffEntry {
  // Structural path for traceability and Apply-time correlation.
  // Bracket notation uses the natural key (name) of array items
  // rather than the array index, so it remains stable across reorders.
  path: string;

  // Engineer-facing label. "Mash Hold: hold time", "Equipment
  // requirement added: Cooling", "Formula Small Batch: Binder Mass".
  label: string;

  // Pre-formatted before/after values. Empty string for adds (no
  // before) and removes (no after).
  before: string;
  after: string;

  // Severity hint for the UI to colour / group entries. Renderers
  // may invent additional values, but these are the conventions:
  //   "structural"  — added / removed step, requirement, branch
  //   "parameter"   — value inside an existing structure
  //   "annotation"  — metadata-only (description, author, etc.)
  impact?: "structural" | "parameter" | "annotation" | string;
}

Renderer contract

Renderers must be:

  • Deterministic — same inputs, same outputs.
  • Side-effect-free — no network, no DOM, no globals beyond window.__diffRendererUtils.
  • Tolerant — accept missing/null sub-objects and treat them as empty. The framework guarantees a kind match but not field shape.
  • Stable in ordering — emit entries in a deterministic order so the UI's diff output is reproducible.
  • Default-aware — a sparse declared manifest and a re-defaulted cluster object are the same state, and must render no entry (#825). Three equivalences apply:
    • Absent vs server-applied default: a field carrying a +kubebuilder:default marker (e.g. SFCStep.waitForAction, Unit.spec.mode) is normalised through __diffRendererUtils.withDefault(value, default) on both sides before comparing, with the Go marker named in a comment at the call site. This also makes a real change render the effective value ("Manual -> Automatic", where "Manual -> (empty)" would mislead).
    • Zero vs absent: non-pointer omitempty scalars (timeoutSeconds, transition priority, required) cannot round-trip their zero value through the cluster, so an explicit 0/false in a manifest equals an absent field. The duration helpers fold 0 into the unset state.
    • Unset states: undefined, null, and '' are one state, and the shared pushScalarChange helpers skip when both sides are unset (#749).

Worked example: the recipe renderer

internal/gateway/static/js/diff-renderers/recipe.js is registered for MasterRecipe (and for the cluster-scoped recipe-template kinds when their UI flows are re-enabled, since the renderer is shape-driven). It walks:

Spec section Diff treatment
Top-level metadata (description, productName, version, author, …) Per-field scalar entries with impact: "annotation" (or "structural" for targetProcessCell).
header Per-field scalar entries with impact: "parameter".
parameters (RecipeParameter list) Add / remove / per-field modify (type, defaultValue, minValue, maxValue, engineeringUnit, description). TIME-typed defaults are normalised through parseISA88Duration and rendered via formatDuration.
equipmentRequirements Add / remove with summary; modify emits per-field entries (minCount and a sub-diff of parameters).
procedureSteps (deprecated flat) Step add / remove with structural summary. Modify emits per-field entries (template refs, capability, description, timeoutSeconds rendered via formatDuration, plus a sub-diff of per-step parameters and parameterBindings). Reorder of an unchanged set surfaces as a single Step order entry.
procedure.chart (SFC) Add / remove of the chart, plus sub-diffs of steps, transitions (keyed by from->to) and divergences.
formulas (named parameter sets) Add / remove with summary; per-formula sub-diff of parameters, materialInputs, materialOutputs. FormulaParameter.value formats with engineeringUnit when present.

Step names are humanised before being placed in label: mash-hold becomes Mash Hold, binderMass becomes Binder Mass, so an engineer never sees the raw identifier in the preview unless they look at path.

Unit conversions

The renderer applies these conversions automatically:

  • Numeric *Seconds fields (e.g. timeoutSeconds, holdSeconds) and the per-step parameters[…] entries whose name matches that pattern are rendered through formatDuration (1800 → "30 min").
  • RecipeParameter entries whose type is "TIME" accept any of IEC 61131 (T#1h30m, 30m), ISO 8601 (PT45M), or bare seconds (1800), and render through formatDuration.
  • FormulaParameter.value is rendered with the parameter's engineeringUnit appended ("7.5 kg").

Writing a new renderer

To add coverage for a new CRD kind (e.g. PhaseTemplate, AlarmDefinition), drop a file at internal/gateway/static/js/diff-renderers/<kind>.js following the recipe pattern:

  1. Define the function: renderXDiff(before, after, ctx), returning an array of entries. Walk the spec, and for each user-meaningful field push a DiffEntry.
  2. Register on load. Call registerDiffRenderer('<Kind>', renderXDiff) inside an if (typeof window !== 'undefined') guard so node:test fixtures can register manually.
  3. Add to the entry HTMLs. Append a <script src= "/js/diff-renderers/<kind>.js"> line to the relevant internal/gateway/static/{system,hmi,data}/index.html (after diff-renderer.js).
  4. Test it. Add fixtures to test/js/diff-renderer-<kind>.test.js covering at minimum: no-op, scalar field change, add inside a list, remove inside a list, structural reorder. The recipe tests are the reference shape. Copy aggressively.
  5. Run make test-js and make lint-js.

The shared helpers __diffRendererUtils.indexByKey(arr, key) and __diffRendererUtils.deepEqual(a, b) are exposed on window for renderer use. Prefer them over rolling your own.

When to extract a sub-family helper file

The procedural-template renderers (PhaseTemplate, OperationTemplate, UnitProcedureTemplate) share enough surface area (same ParameterSpec, same OutputSpec, same SFCChart) that a sibling helper file (diff-renderers/_procedural-helpers.js) earns its keep. The convention is:

  • Underscore-prefixed filename so the helper is visibly not a registered renderer.
  • Helpers are exposed on window.__procDiffHelpers (and a CommonJS export for node:test) so each renderer can pull only the pieces it needs.
  • One renderer file per kind still, even when the bulk of the work flows through helpers. The registry contract is one function per CRD kind, and a thin renderer file is the right place to make domain-specific labeling decisions ("Phase added" vs "Operation added" vs "Step (Action) added").

Don't over-share: the recipe renderer's RecipeParameter shape uses engineeringUnit where the procedural family uses unit, and its SFC step shape contains phaseTemplateRef where the generic shape carries templateRef. A single catch-all helper would have to disambiguate, so the recipe renderer keeps its own copies of the parameter-summary / step-summary formatters.

Why this and not a YAML-text diff

Two concrete reasons:

  1. Domain language. holdSeconds: 1800 -> 2700 reads as Greek to an engineer who thinks in minutes. Mash Hold: hold time 30 min -> 45 min reads as the change they intended. The framework exists precisely to do that translation.
  2. Stable identity under reorder. A YAML diff treats move array element from index 2 to index 0 as a sea of +/- lines. The semantic renderer keys arrays by their natural identifier (name, capability, from->to). A reorder therefore collapses to a single Step order: A -> B -> C => B -> A -> C entry that reviewers can actually read.

A "View YAML diff" toggle is still always one click away, per the always allowed to peek under the hood principle in the explanation doc. The semantic diff is a UX layer on top of the truth. The truth stays reachable.

Open questions

These do not block #287 but inform the long-tail work in #292:

  1. Conflict surface. When desiredObject is stale (the live resource has moved since the preview was computed), should the diff renderer flag conflicting fields or leave that to the apply step? Current answer: leave it to apply, since the renderer is a pure function of (before, after).
  2. i18n. Labels are English-only today. If a customer needs localisation, a label-table indirection would be the right shape. It is not yet justified.
  3. Per-field severity overrides. Some fields (e.g. targetProcessCell) deserve a louder marker than the generic structural hint. We may grow a severity: "warning" | "info" annotation, but want to see real customer review traffic before deciding the shape.

Acceptance for #287

  • Registry API in place at internal/gateway/static/js/diff-renderer.js.
  • Recipe renderer covers operations / parameters / equipment requirements / SFC procedure / formulas / unit conversions, with tests in test/js/diff-renderer-recipe.test.js.
  • make test-js green, and make lint-js, make lint-css, make lint-icons pass.
  • This doc merged so #292 contributors can pick up new kinds without rediscovering the contract.