v0.4.2: operational layer — engine ctrl, tables CRUD, xlate viz, smat-diff, create-thread, tclgen

Seven new lib tools — covers the remaining Bryan-requested gaps.

lib/nc-engine.sh
  - Cloverleaf process control. Wraps shipped binaries (hcienginestop,
    hcienginerun, hcienginerestart, hciengineroutetest). Every action
    is Y/N confirmed AND journaled into engine-actions.tsv.
  - Subcommands: stop, start, bounce/restart, status, resend-ib,
    resend-ob, route-test, testxlate, tpstest.

lib/nc-status.sh
  - Runtime status, v1-modelled. Subcommands: sites, threads, not-up,
    connections, queued, raw. Auto-discovers hcienginestat / tstat /
    connstatus binaries; falls back to file-presence heuristics.

lib/nc-table.sh
  - Read+CRUD for .tbl lookup tables. Subcommands: list, show, pairs
    (→csv/tsv), lookup, reverse-lookup, add, delete, create, replace.
  - All modifications journal-backed. Composes csv-to-table /
    table-to-csv for format conversion.

lib/nc-xlate.sh
  - Visualize .xlt files. Parses the TCL nested-block ops format.
    Subcommands: list, show, ops (TSV), tree (ASCII flow), summary
    (counts + segments + tables touched), diff (cross-xlate).
  - Confirmed working against Epic_ADT_CodaMetrix.xlt: identified
    12 PATHCOPY + 1 COPY ops across MSH/EVN/PID/PV1/PV2/PD1/ZPD/ZPV/
    AL1/GT1/IN1/IN2.

lib/nc-smat-diff.sh
  - Cross-env smat content diff. Samples N msgs from each side,
    pairs by configurable HL7 field (default MSH.10 = control ID),
    hl7-diffs each pair with --ignore MSH.7. Outputs per-pair reports
    + master _summary.md with paired/A-only/B-only counts.

lib/nc-create-thread.sh
  - High-level: create a new protocol + optionally splice a route from
    an existing thread to the new one. Both writes journal-backed.
    Confirmed end-to-end: created to_metrics_test outbound + routed
    IB_ADT_muxS → to_metrics_test via journal entries 001+002.

lib/nc-tclgen.sh
  - TCL UPOC scaffolding from intent. Templates: tps-presc, tps-postsc,
    tps-iclkill, xlate-helper, trxid, ack, field-rewrite. Produces
    clean syntax-correct TCL ready to edit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Bryan Johnson 2026-05-26 11:11:30 -07:00
parent 3eb88f86c8
commit a0502e2ec6
10 changed files with 1392 additions and 2 deletions

View File

@ -1 +1 @@
0.4.1
0.4.2

View File

@ -99,6 +99,13 @@ fetch lib/each-site.sh "$LARRY_HOME/lib/each-site.sh"
fetch lib/len2nl.sh "$LARRY_HOME/lib/len2nl.sh"
fetch lib/csv-to-table.sh "$LARRY_HOME/lib/csv-to-table.sh"
fetch lib/table-to-csv.sh "$LARRY_HOME/lib/table-to-csv.sh"
fetch lib/nc-engine.sh "$LARRY_HOME/lib/nc-engine.sh"
fetch lib/nc-status.sh "$LARRY_HOME/lib/nc-status.sh"
fetch lib/nc-table.sh "$LARRY_HOME/lib/nc-table.sh"
fetch lib/nc-xlate.sh "$LARRY_HOME/lib/nc-xlate.sh"
fetch lib/nc-smat-diff.sh "$LARRY_HOME/lib/nc-smat-diff.sh"
fetch lib/nc-create-thread.sh "$LARRY_HOME/lib/nc-create-thread.sh"
fetch lib/nc-tclgen.sh "$LARRY_HOME/lib/nc-tclgen.sh"
fetch lib/nc-parse.sh "$LARRY_HOME/lib/nc-parse.sh"
fetch lib/nc-inbound.sh "$LARRY_HOME/lib/nc-inbound.sh"
fetch lib/nc-make-jump.sh "$LARRY_HOME/lib/nc-make-jump.sh"

View File

@ -32,7 +32,7 @@ set -o pipefail
# ─────────────────────────────────────────────────────────────────────────────
# Config
# ─────────────────────────────────────────────────────────────────────────────
LARRY_VERSION="0.4.1"
LARRY_VERSION="0.4.2"
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
LARRY_UPDATE_URL="${LARRY_UPDATE_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main/larry.sh}"
LARRY_AGENTS_URL="${LARRY_AGENTS_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main/agents}"

199
lib/nc-create-thread.sh Executable file
View File

@ -0,0 +1,199 @@
#!/usr/bin/env bash
# nc-create-thread.sh — high-level: create a new thread in a NetConfig and
# (optionally) wire a route from another thread to it.
#
# Combines nc-make-jump's emit helpers + nc-insert-protocol for a single
# user-facing operation. Goes through the journal.
#
# Usage:
# nc-create-thread.sh --name NEW_THREAD --site SITE --netconfig PATH
# --type tcpip|file
# --direction inbound|outbound
# --port PORT [--host HOST]
# [--process PROC]
# [--encoding ASCII]
# [--connect-from EXISTING_THREAD] # add a route on EXISTING → NEW
# [--route-type raw|xlate|generate] # default raw
# [--xlate XLATENAME] # if route-type=xlate
# [--trxid REGEX] # default .*
#
# Example: create a new outbound thread `to_metrics` in process `metrics` on
# 10.0.0.50:51999, raw-route from existing `IB_ADT_muxS` to it.
set -o pipefail
NC_SELF="$0"
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
NCP="$LIB_DIR/nc-parse.sh"
NCI="$LIB_DIR/nc-insert-protocol.sh"
die() { printf 'nc-create-thread: %s\n' "$*" >&2; exit 1; }
NAME=""; SITE="${HCISITE:-}"; NC=""
TYPE="tcpip"; DIRECTION="outbound"; PORT=""; HOST=""
PROCESS=""; ENCODING="ASCII"
CONNECT_FROM=""; ROUTE_TYPE="raw"; XLATE=""; TRXID=".*"
while [ $# -gt 0 ]; do
case "$1" in
--name) shift; NAME="$1" ;;
--site) shift; SITE="$1" ;;
--netconfig) shift; NC="$1" ;;
--type) shift; TYPE="$1" ;;
--direction) shift; DIRECTION="$1" ;;
--port) shift; PORT="$1" ;;
--host) shift; HOST="$1" ;;
--process) shift; PROCESS="$1" ;;
--encoding) shift; ENCODING="$1" ;;
--connect-from) shift; CONNECT_FROM="$1" ;;
--route-type) shift; ROUTE_TYPE="$1" ;;
--xlate) shift; XLATE="$1" ;;
--trxid) shift; TRXID="$1" ;;
-h|--help) sed -n '2,20p' "$NC_SELF"; exit 0 ;;
*) die "unknown arg: $1" ;;
esac
shift
done
[ -n "$NAME" ] || die "missing --name"
[ -n "$PORT" ] || die "missing --port"
[ -n "$NC" ] || NC="${HCIROOT:-}/${SITE}/NetConfig"
[ -f "$NC" ] || die "no such netconfig: $NC"
[ -z "$PROCESS" ] && PROCESS="$SITE"
is_server=0
outonly=1
obib=0
case "$DIRECTION" in
inbound) is_server=1; outonly=0; obib=1 ;;
outbound) is_server=0; outonly=1; obib=0 ;;
*) die "bad --direction" ;;
esac
# Build the protocol block
build_block() {
cat <<EOF
protocol ${NAME} {
{ AUTOSTART 1 }
{ BITMAP {} }
{ COORDS {0 0} }
{ DATAFORMAT {
{ FRLTYPE offlen }
{ OFFLEN { { LEN 0 } { OFF 0 } } }
{ TYPE frl }
} }
{ DATAXLATE {
} }
{ EDIBATCH {
{ IN_DATA { { TYPE {} } { VERSION {} } } }
{ OUT_DATA { { HEADER {} } { TRIGGER { { COUNT {} } { SCHEDULER {} } { TIMER {} } } } { TYPE {} } { VERSION {} } } }
} }
{ ENCODING ${ENCODING} }
{ ENCODING_BOM_IB 0 }
{ ENCODING_BOM_OB 0 }
{ ENCODING_HL7 0 }
{ ENCODING_XML 0 }
{ EOCONFIG {} }
{ ERRDBTPS {
{ ERRTPSPROCS { { ARGS {} } { PROCS {} } { PROCSCONTROL {} } } }
{ RETRIES -1 }
} }
{ GROUPS {larry_created} }
{ HOSTDOWN 0 }
{ ICLSERVERPORT {} }
{ KEEPMSGONDISK 0 }
{ META {} }
{ OBWORKASIB ${obib} }
{ OUTBOUNDONLY ${outonly} }
{ PROCESSNAME ${PROCESS} }
{ PROTOCOL {
{ CA_FILE {} }
{ CA_PATH {} }
{ CERT_FILE {} }
{ CIPHERSUITES {} }
{ CLOSE 0 }
{ CONTROLMSGS 0 }
{ COPYCLIENTIPP 0 }
{ DELAYCONNECT 0 }
{ ENCODE_FILL {} }
{ ENCODE_INCLUSIVE 1 }
{ ENCODE_ISNATIVE 0 }
{ ENCODE_JUST r }
{ ENCODE_LEN 4 }
{ ENCODE_TYPE encapsulated }
{ HOST ${HOST:-{}} }
{ IPV4_V6_DUAL 0 }
{ IS_SSL 0 }
{ ISMULTI 0 }
{ ISSERVER ${is_server} }
{ LOCAL_IP {} }
{ MAXCLIENT 0 }
{ MAXOBQD 0 }
{ MAXPREXLTQD 0 }
{ MLP_ERROR RESET }
{ MLP_MODE MLP }
{ MLP_TIMEOUT 30 }
{ MODE {} }
{ PASSWORD {} }
{ PORT ${PORT} }
{ PRIVATE_KEY {} }
{ RECONNECT 1 }
{ REOPEN 5 }
{ SSL_PROTOCOL All }
{ TCP_CONNECTION_TIMEOUT {} }
{ TYPE ${TYPE} }
{ WRITEZERO 0 }
} }
{ RECVCONTROL { { ACKCONTROL { { ARGS {} } { PROCS {} } { PROCSCONTROL {} } } } { EOMSG {} } { HOLDMSGS 0 } { MSGPRIO 5120 } } }
{ SAVECONTROL { { ARGS {} } { PROCS {} } { PROCSCONTROL {} } } }
{ TPS_INBOUND { { ARGS {} } { PROCS {} } { PROCSCONTROL {} } } }
{ TPS_OUTBOUND { { ARGS {} } { PROCS {} } { PROCSCONTROL {} } } }
{ TRACING 0 }
}
EOF
}
build_route_entry() {
local xlate_block="{ PROCS { { ARGS {} } { PROCS {} } { PROCSCONTROL {} } } }"
local extra=""
if [ "$ROUTE_TYPE" = "xlate" ] && [ -n "$XLATE" ]; then
extra="{ XLATE ${XLATE} }"
fi
cat <<EOF
{
{ CACHEMSG 0 }
{ DEL_ON_ERR_ROUTE 0 }
{ ROUTE_DETAILS {
{
{ DEST ${NAME} }
${xlate_block}
${extra}
{ TYPE ${ROUTE_TYPE} }
}
} }
{ ROUTE_ENABLED 1 }
{ TRXID ${TRXID} }
{ WILDCARD ON }
}
EOF
}
# Generate block
BLOCK_FILE=$(mktemp); build_block > "$BLOCK_FILE"
printf '\n=== Generated protocol block for %s ===\n\n' "$NAME"
cat "$BLOCK_FILE"
printf '\n'
# Insert it
"$NCI" insert "$NC" "$BLOCK_FILE"
# If --connect-from, also splice a route into the source thread's DATAXLATE
if [ -n "$CONNECT_FROM" ]; then
ROUTE_FILE=$(mktemp); build_route_entry > "$ROUTE_FILE"
printf '\n=== Route to splice into %s DATAXLATE ===\n\n' "$CONNECT_FROM"
cat "$ROUTE_FILE"
printf '\n'
"$NCI" add-route "$NC" "$CONNECT_FROM" "$ROUTE_FILE"
rm -f "$ROUTE_FILE"
fi
rm -f "$BLOCK_FILE"

201
lib/nc-engine.sh Executable file
View File

@ -0,0 +1,201 @@
#!/usr/bin/env bash
# nc-engine.sh — Cloverleaf engine process control. Native v3 wrapper
# around the shipped Cloverleaf binaries — modelled on v1 `bounce`,
# `bounce_processes`, `pstop`, `start`, etc.
#
# Every action goes through the journal so it's reversible. Bounces are
# journaled as paired stop+start records; the rollback executes them in
# reverse to restore prior state (best-effort — engine state can drift).
#
# Subcommands:
# stop <thread|process> [more...] stop one or more processes/threads
# start <thread|process> [more...] start one or more
# bounce <thread|process> [more...] stop then start (atomic-ish)
# restart alias of bounce
# status quick site status via tstat (if available)
# resend-ob <thread> <file> resend a file outbound (post-xlate)
# resend-ib <thread> <file> resend a file inbound (pre-xlate)
# route-test <thread> <file> run Cloverleaf route_test for a thread
# testxlate <xlate> <xltfile> test an xlate against an xlt file
# tpstest <msgfile> <proc-args> run a TPS test
#
# Options for stop/start/bounce:
# --site SITE override $HCISITE for this call
# --confirm yes skip Y/N prompt (still journaled)
# --dry-run show the binary command but do not execute
#
# Cloverleaf binaries used (auto-discovered under $HCIROOT/bin/):
# hcienginestop hcienginerun hcienginerestart hcienginestat tstat
# hciengineroutetest hciengineenginesend ...
set -o pipefail
NC_SELF="$0"
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
JOURNAL="$LIB_DIR/journal.sh"
die() { printf 'nc-engine: %s\n' "$*" >&2; exit 1; }
warn() { printf 'nc-engine: %s\n' "$*" >&2; }
# Source journal so journaled actions can call journal_write
[ -f "$JOURNAL" ] && . "$JOURNAL" || warn "journal.sh not available — actions will not be reversible"
resolve_binary() {
local name="$1"
if command -v "$name" >/dev/null 2>&1; then command -v "$name"; return; fi
for d in "${HCIROOT:-}/bin" "${HCIROOT:-}/server/bin"; do
[ -x "$d/$name" ] && { echo "$d/$name"; return; }
done
return 1
}
journal_action() {
# Record an engine action in the journal as a synthetic "command" entry.
# We don't snapshot files (these are runtime ops, not file edits) but we
# write a manifest-style entry so larry-rollback.sh --list shows them.
local action="$1" target="$2" detail="${3:-}"
local sessdir="$LARRY_HOME/journal/${LARRY_SESSION_ID:-engine-$(date +%Y-%m-%d-%H%M%S)-$$}"
mkdir -p "$sessdir" 2>/dev/null
local idx; idx=$(printf '%03d' $(($(find "$sessdir" -name '[0-9]*.engine' 2>/dev/null | wc -l) + 1)))
local entry="$sessdir/${idx}_${action}_${target//\//_}.engine"
{
printf 'action: %s\ntarget: %s\nwhen: %s\nhost: %s\nhciroot: %s\nhcisite: %s\ndetail: %s\n' \
"$action" "$target" "$(date -Iseconds 2>/dev/null || date)" \
"$(hostname 2>/dev/null || echo unknown)" "${HCIROOT:-?}" "${HCISITE:-?}" "$detail"
} > "$entry"
# Also append to a flat engine log for quick listing
local elog="$LARRY_HOME/journal/engine-actions.tsv"
[ -f "$elog" ] || printf 'when\tsession\taction\ttarget\thciroot\thcisite\n' > "$elog"
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -Iseconds 2>/dev/null || date)" \
"${LARRY_SESSION_ID:-?}" "$action" "$target" "${HCIROOT:-?}" "${HCISITE:-?}" >> "$elog"
}
run_action() {
local action="$1" target="$2"; shift 2
local site="${HCISITE:-}"
local confirm=""
local dry=0
while [ $# -gt 0 ]; do
case "$1" in
--site) shift; site="$1" ;;
--confirm) shift; confirm="$1" ;;
--dry-run) dry=1 ;;
esac
shift
done
local binary cmd label
case "$action" in
stop) binary=$(resolve_binary hcienginestop) || die "hcienginestop not found"; cmd="$binary -p $target"; label="STOP" ;;
start) binary=$(resolve_binary hcienginerun) || die "hcienginerun not found"; cmd="$binary -p $target"; label="START" ;;
bounce|restart)
binary=$(resolve_binary hcienginerestart) \
&& cmd="$binary -p $target" && label="BOUNCE" \
|| {
# Fallback to stop + start
local sbin; sbin=$(resolve_binary hcienginestop) || die "hcienginestop+hcienginerestart both missing"
local rbin; rbin=$(resolve_binary hcienginerun) || die "hcienginerun missing"
cmd="$sbin -p $target && $rbin -p $target"
label="BOUNCE"
} ;;
*) die "unknown action: $action" ;;
esac
printf '\n%s%s%s thread/process=%s site=%s\n' "${C_YELLOW:-}" "$label" "${C_RESET:-}" "$target" "${site:-?}"
printf ' $ %s\n' "$cmd"
if [ "$dry" = "1" ]; then
printf ' [dry-run] not executed\n'
return 0
fi
if [ "$confirm" != "yes" ]; then
printf ' proceed? [y/N]: '
read -r ans </dev/tty 2>/dev/null || ans=""
[[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED by user"; return 1; }
fi
journal_action "$action" "$target" "$cmd"
HCISITE="$site" eval "$cmd"
local rc=$?
if [ "$rc" -eq 0 ]; then echo " ✓ ok"; else warn " exit $rc"; fi
return $rc
}
cmd_status() {
local site="${HCISITE:-}"
local binary
binary=$(resolve_binary hcienginestat) || binary=$(resolve_binary tstat) || die "no engine-status binary on PATH (looked for hcienginestat, tstat)"
HCISITE="$site" "$binary" "$@"
}
cmd_resend() {
local kind="$1" thread="$2" file="$3"; shift 3
[ -n "$thread" ] && [ -f "$file" ] || die "usage: resend-{ib,ob} <thread> <file>"
local cmd
case "$kind" in
ob) cmd="$thread resend_ob $file" ;;
ib) cmd="$thread resend_ib $file" ;;
*) die "bad resend kind: $kind" ;;
esac
printf '\nRESEND-%s thread=%s file=%s\n $ %s\n proceed? [y/N]: ' "${kind^^}" "$thread" "$file" "$cmd"
read -r ans </dev/tty 2>/dev/null || ans=""
[[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; }
journal_action "resend-$kind" "$thread" "file=$file"
eval "$cmd"
}
cmd_route_test() {
local thread="$1" file="$2"
[ -n "$thread" ] && [ -f "$file" ] || die "usage: route-test <thread> <file>"
local cmd="$thread route_test $file"
printf '\nROUTE-TEST thread=%s input=%s\n $ %s\n proceed? [y/N]: ' "$thread" "$file" "$cmd"
read -r ans </dev/tty 2>/dev/null || ans=""
[[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; }
journal_action "route-test" "$thread" "file=$file"
eval "$cmd"
}
cmd_testxlate() {
local xlate="$1" xltfile="$2"
[ -n "$xlate" ] && [ -f "$xltfile" ] || die "usage: testxlate <xlate> <xltfile>"
local cmd="testxlate $xlate $xltfile"
printf '\nTESTXLATE xlate=%s file=%s\n $ %s\n proceed? [y/N]: ' "$xlate" "$xltfile" "$cmd"
read -r ans </dev/tty 2>/dev/null || ans=""
[[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; }
journal_action "testxlate" "$xlate" "file=$xltfile"
eval "$cmd"
}
cmd_tpstest() {
local msgfile="$1"; shift
[ -f "$msgfile" ] || die "usage: tpstest <msgfile> <proc-args...>"
local procs; procs="$*"
local cmd="tpstest $msgfile $procs"
printf '\nTPSTEST msgfile=%s procs=%s\n $ %s\n proceed? [y/N]: ' "$msgfile" "$procs" "$cmd"
read -r ans </dev/tty 2>/dev/null || ans=""
[[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; }
journal_action "tpstest" "$msgfile" "procs=$procs"
eval "$cmd"
}
SUB="${1:-help}"
case "$SUB" in
stop|start|bounce|restart)
shift
[ $# -ge 1 ] || die "usage: $SUB <target> [more...] [--site SITE] [--confirm yes] [--dry-run]"
# Separate targets from flags
targets=(); flags=()
while [ $# -gt 0 ]; do
case "$1" in --*) flags+=("$1" "${2:-}"); shift 2 ;; *) targets+=("$1"); shift ;; esac
done
for t in "${targets[@]}"; do run_action "$SUB" "$t" "${flags[@]}"; done
;;
status) shift; cmd_status "$@" ;;
resend-ob) shift; cmd_resend ob "$@" ;;
resend-ib) shift; cmd_resend ib "$@" ;;
route-test) shift; cmd_route_test "$@" ;;
testxlate) shift; cmd_testxlate "$@" ;;
tpstest) shift; cmd_tpstest "$@" ;;
help|-h|--help) sed -n '2,30p' "$NC_SELF" ;;
*) die "unknown subcommand: $SUB" ;;
esac

161
lib/nc-smat-diff.sh Executable file
View File

@ -0,0 +1,161 @@
#!/usr/bin/env bash
# nc-smat-diff.sh — diff smat (message archive) content across two environments.
# Different from nc-regression which runs route_test; this just samples actual
# stored messages from each env's smatdb and compares them.
#
# Pairing strategy:
# By default, sample N most-recent messages from each side, then pair by
# MSH.10 (message control ID) — the standard HL7 unique identifier.
# Unmatched IDs are reported as A-only / B-only.
#
# Usage:
# nc-smat-diff.sh <thread> --env-a HCIROOT_A --env-b HCIROOT_B [opts]
#
# Options:
# --site-a SITE site on env-A (default $HCISITE)
# --site-b SITE site on env-B (default same as --site-a)
# --limit N messages to sample per side (default 50)
# --ignore FIELDS hl7-diff --ignore list (default "MSH.7")
# --out DIR output directory for per-msg diffs + summary
# --pair-on FIELD HL7 field to pair messages on (default MSH.10)
# --include-history include SmatHistory archives on both sides
# --after EXPR only messages after this time (e.g. "3 days ago")
set -o pipefail
NC_SELF="$0"
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
NCM="$LIB_DIR/nc-msgs.sh"
HL7F="$LIB_DIR/hl7-field.sh"
HL7DIFF="$LIB_DIR/hl7-diff.sh"
die() { printf 'nc-smat-diff: %s\n' "$*" >&2; exit 1; }
THREAD=""
ENV_A=""
ENV_B=""
SITE_A="${HCISITE:-}"
SITE_B=""
LIMIT=50
IGNORE="MSH.7"
OUT=""
PAIR_ON="MSH.10"
INC_HIST=0
AFTER=""
while [ $# -gt 0 ]; do
case "$1" in
--env-a) shift; ENV_A="$1" ;;
--env-b) shift; ENV_B="$1" ;;
--site-a) shift; SITE_A="$1" ;;
--site-b) shift; SITE_B="$1" ;;
--limit) shift; LIMIT="$1" ;;
--ignore) shift; IGNORE="$1" ;;
--out) shift; OUT="$1" ;;
--pair-on) shift; PAIR_ON="$1" ;;
--include-history) INC_HIST=1 ;;
--after) shift; AFTER="$1" ;;
-h|--help) sed -n '2,22p' "$NC_SELF"; exit 0 ;;
-*) die "unknown flag: $1" ;;
*) [ -z "$THREAD" ] && THREAD="$1" || die "extra arg: $1" ;;
esac
shift
done
[ -n "$THREAD" ] || die "missing thread name"
[ -n "$ENV_A" ] && [ -n "$ENV_B" ] || die "--env-a and --env-b required"
[ -n "$SITE_A" ] || die "--site-a required (or set HCISITE)"
[ -z "$SITE_B" ] && SITE_B="$SITE_A"
[ -z "$OUT" ] && OUT=$(mktemp -d)
mkdir -p "$OUT/a" "$OUT/b" "$OUT/diff" 2>/dev/null
dump_side() {
local hciroot="$1" site="$2" target_dir="$3"
local nc_args=("$THREAD" --limit "$LIMIT" --format raw)
[ "$INC_HIST" = "1" ] && nc_args+=(--include-history)
[ -n "$AFTER" ] && nc_args+=(--after "$AFTER")
HCISITEDIR="$hciroot/$site" "$NCM" "${nc_args[@]}" > "$target_dir/all.raw" 2>"$target_dir/err"
# Split into individual messages on 0x1c, and index by pair-on field
awk -v RS=$'\x1c' -v dir="$target_dir" '
NF > 0 || $0 != "" {
n++
fpath = dir "/msg_" sprintf("%05d", n) ".hl7"
printf "%s", $0 > fpath
close(fpath)
}
' "$target_dir/all.raw"
# Build pair-on-field → file index
: > "$target_dir/index.tsv"
for f in "$target_dir"/msg_*.hl7; do
[ -f "$f" ] || continue
local key; key=$("$HL7F" "$PAIR_ON" "$f" 2>/dev/null | head -1)
[ -z "$key" ] && key="(no-$PAIR_ON)"
printf '%s\t%s\n' "$key" "$f" >> "$target_dir/index.tsv"
done
}
printf 'nc-smat-diff:\n thread: %s\n A: %s/%s\n B: %s/%s\n limit: %d ignore: %s pair-on: %s\n out: %s\n\n' \
"$THREAD" "$ENV_A" "$SITE_A" "$ENV_B" "$SITE_B" "$LIMIT" "$IGNORE" "$PAIR_ON" "$OUT" >&2
dump_side "$ENV_A" "$SITE_A" "$OUT/a"
dump_side "$ENV_B" "$SITE_B" "$OUT/b"
A_COUNT=$(wc -l < "$OUT/a/index.tsv" | tr -d ' ')
B_COUNT=$(wc -l < "$OUT/b/index.tsv" | tr -d ' ')
printf 'A: %d msgs B: %d msgs\n\n' "$A_COUNT" "$B_COUNT" >&2
# Pair messages by key
sort -k1 "$OUT/a/index.tsv" > "$OUT/a/sorted.tsv"
sort -k1 "$OUT/b/index.tsv" > "$OUT/b/sorted.tsv"
A_KEYS=$(awk -F'\t' '{print $1}' "$OUT/a/sorted.tsv" | sort -u)
B_KEYS=$(awk -F'\t' '{print $1}' "$OUT/b/sorted.tsv" | sort -u)
SUMMARY="$OUT/_summary.md"
{
printf '# smat diff: thread=%s\n\n' "$THREAD"
printf '- A: `%s/%s` (%d messages sampled)\n' "$ENV_A" "$SITE_A" "$A_COUNT"
printf '- B: `%s/%s` (%d messages sampled)\n' "$ENV_B" "$SITE_B" "$B_COUNT"
printf '- pair-on: `%s`\n' "$PAIR_ON"
printf '- ignore: `%s`\n\n' "$IGNORE"
printf '## Per-pair diffs\n\n'
printf '| %s | diffs | report |\n|---|---|---|\n' "$PAIR_ON"
} > "$SUMMARY"
DIFFS_TOTAL=0
A_ONLY=0
B_ONLY=0
PAIRED=0
while IFS= read -r key; do
[ -z "$key" ] && continue
a_files=$(grep -F "${key} " "$OUT/a/sorted.tsv" | awk -F'\t' '{print $2}' | head -1)
b_files=$(grep -F "${key} " "$OUT/b/sorted.tsv" | awk -F'\t' '{print $2}' | head -1)
if [ -z "$a_files" ] && [ -n "$b_files" ]; then
B_ONLY=$((B_ONLY+1))
echo "| \`$key\` | (only on B) | — |" >> "$SUMMARY"
elif [ -z "$b_files" ] && [ -n "$a_files" ]; then
A_ONLY=$((A_ONLY+1))
echo "| \`$key\` | (only on A) | — |" >> "$SUMMARY"
else
PAIRED=$((PAIRED+1))
report="$OUT/diff/${key//\//_}.md"
cnt=$("$HL7DIFF" --ignore "$IGNORE" --format count "$a_files" "$b_files" 2>/dev/null || echo "?")
{
printf '# msg %s\n\nA: `%s`\nB: `%s`\n\n' "$key" "$a_files" "$b_files"
"$HL7DIFF" --ignore "$IGNORE" "$a_files" "$b_files" 2>/dev/null
} > "$report"
echo "| \`$key\` | $cnt | [report](./diff/$(basename "$report")) |" >> "$SUMMARY"
DIFFS_TOTAL=$((DIFFS_TOTAL + ${cnt:-0}))
fi
done < <(printf '%s\n%s\n' "$A_KEYS" "$B_KEYS" | sort -u)
{
printf '\n## Summary\n\n'
printf '- paired (A and B): %d\n' "$PAIRED"
printf '- A-only: %d\n' "$A_ONLY"
printf '- B-only: %d\n' "$B_ONLY"
printf '- total field differences (post-ignore): %d\n' "$DIFFS_TOTAL"
} >> "$SUMMARY"
printf 'done. Summary: %s\n' "$SUMMARY" >&2
echo "$SUMMARY"

152
lib/nc-status.sh Executable file
View File

@ -0,0 +1,152 @@
#!/usr/bin/env bash
# nc-status.sh — Cloverleaf engine runtime status. Native v3 wrapper around
# the shipped status/tstat binaries — modelled on v1 `status`, `tstats`,
# `tstat`, `thread_status`, `procstatus`, `dstatus`, `connstatus`, `not_up`.
#
# Subcommands:
# sites site-level status (daemon + processes + threads)
# threads thread-level status (port, state, host)
# not-up only threads not in 'up' state
# connections raw connection state (from connstatus binary if available)
# queued threads with messages queued
# raw pass-through to the underlying tstat binary
#
# Flags (where applicable):
# --site SITE single site; default = all sites under $HCIROOT
# --filter REGEX match on thread name
# --format text|tsv default text
#
# This is V1-modeled but native-bash. The exact binary names depend on the
# Cloverleaf version; we auto-discover under $HCIROOT/bin (and server/bin).
set -o pipefail
NC_SELF="$0"
die() { printf 'nc-status: %s\n' "$*" >&2; exit 1; }
warn() { printf 'nc-status: %s\n' "$*" >&2; }
resolve_binary() {
local name="$1"
if command -v "$name" >/dev/null 2>&1; then command -v "$name"; return; fi
for d in "${HCIROOT:-}/bin" "${HCIROOT:-}/server/bin"; do
[ -x "$d/$name" ] && { echo "$d/$name"; return; }
done
return 1
}
list_sites() {
local root="${HCIROOT:-}"
[ -d "$root" ] || die "no \$HCIROOT or it doesn't exist"
find "$root" -mindepth 1 -maxdepth 2 -name NetConfig -type f 2>/dev/null \
| xargs -I{} dirname {} 2>/dev/null \
| xargs -I{} basename {} 2>/dev/null \
| sort -u
}
cmd_sites() {
local target_site=""
local format="text"
while [ $# -gt 0 ]; do
case "$1" in
--site) shift; target_site="$1" ;;
--format) shift; format="$1" ;;
*) die "unknown flag: $1" ;;
esac
shift
done
local hcienginestat; hcienginestat=$(resolve_binary hcienginestat 2>/dev/null || true)
local sites_to_check
if [ -n "$target_site" ]; then sites_to_check="$target_site"; else sites_to_check=$(list_sites); fi
[ "$format" = "tsv" ] && printf 'site\tstate\tdetail\n'
while IFS= read -r s; do
[ -z "$s" ] && continue
local detail="" state="?"
if [ -n "$hcienginestat" ]; then
detail=$(HCISITE="$s" "$hcienginestat" 2>&1 || true)
if echo "$detail" | grep -qiE 'running|active|up'; then state="up"
elif echo "$detail" | grep -qiE 'stopped|down'; then state="down"
else state="unknown"; fi
else
# Fallback: check for lock file / pid file as a poor-man's check
if [ -e "${HCIROOT:-}/$s/lock" ] || [ -e "${HCIROOT:-}/$s/exec/processes" ]; then
state="config-present"
fi
fi
if [ "$format" = "tsv" ]; then
printf '%s\t%s\t%s\n' "$s" "$state" "$(echo "$detail" | head -1)"
else
printf '== %-20s [%s] ==\n%s\n' "$s" "$state" "$(echo "$detail" | head -10)"
fi
done <<< "$sites_to_check"
}
cmd_threads() {
local target_site="${HCISITE:-}"
local filter=""
local format="text"
while [ $# -gt 0 ]; do
case "$1" in
--site) shift; target_site="$1" ;;
--filter) shift; filter="$1" ;;
--format) shift; format="$1" ;;
*) die "unknown flag: $1" ;;
esac
shift
done
local tstat; tstat=$(resolve_binary tstat) || die "tstat binary not found under \$HCIROOT/bin or PATH"
local args=()
[ -n "$target_site" ] && args+=("-s" "$target_site")
local output; output=$(HCISITE="$target_site" "$tstat" "${args[@]}" 2>&1 || true)
if [ -n "$filter" ]; then
output=$(printf '%s' "$output" | grep -E -- "$filter")
fi
if [ "$format" = "tsv" ]; then
# Best-effort: parse the tstat output into TSV
printf '%s\n' "$output" | awk 'NR>1 && NF>0 { gsub(/ +/, "\t"); print }'
else
printf '%s\n' "$output"
fi
}
cmd_not_up() {
local site="${HCISITE:-}"
local filter=""
while [ $# -gt 0 ]; do
case "$1" in
--site) shift; site="$1" ;;
--filter) shift; filter="$1" ;;
*) die "unknown flag: $1" ;;
esac
shift
done
cmd_threads --site "$site" ${filter:+--filter "$filter"} \
| awk 'NR==1 || tolower($0) !~ /\<up\>/'
}
cmd_connections() {
local connstatus; connstatus=$(resolve_binary connstatus 2>/dev/null) || die "connstatus binary not found"
"$connstatus" "$@"
}
cmd_queued() {
local site="${HCISITE:-}"
cmd_threads --site "$site" "$@" \
| awk 'NR==1 || tolower($0) ~ /queued|obq|ibq/'
}
cmd_raw() {
local tstat; tstat=$(resolve_binary tstat) || die "tstat binary not found"
"$tstat" "$@"
}
SUB="${1:-sites}"
case "$SUB" in
sites) shift; cmd_sites "$@" ;;
threads) shift; cmd_threads "$@" ;;
not-up) shift; cmd_not_up "$@" ;;
connections) shift; cmd_connections "$@" ;;
queued) shift; cmd_queued "$@" ;;
raw) shift; cmd_raw "$@" ;;
help|-h|--help) sed -n '2,25p' "$NC_SELF" ;;
*) die "unknown subcommand: $SUB" ;;
esac

223
lib/nc-table.sh Executable file
View File

@ -0,0 +1,223 @@
#!/usr/bin/env bash
# nc-table.sh — read and modify Cloverleaf lookup tables (.tbl files).
# Every modification goes through the journal (snapshot + diff + atomic write).
#
# Subcommands:
# list [--site SITE] list .tbl files
# show <name> [--site SITE] dump the file
# pairs <name> [--site SITE] [--format csv|tsv] input→output pairs only
# lookup <name> <input> find output for input
# reverse-lookup <name> <output> find input(s) for output
# add <name> <input> <output> [--site SITE] add or update a row (journaled)
# delete <name> <input> [--site SITE] delete a row by input (journaled)
# create <name> --from-csv FILE [opts] create a new table from CSV
# replace <name> --from-csv FILE [opts] replace contents (journaled)
#
# Find tables at: $HCISITEDIR/tables/<name>.tbl (or $HCIROOT/Tables/ shared)
set -o pipefail
NC_SELF="$0"
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
JOURNAL="$LIB_DIR/journal.sh"
C2T="$LIB_DIR/csv-to-table.sh"
T2C="$LIB_DIR/table-to-csv.sh"
die() { printf 'nc-table: %s\n' "$*" >&2; exit 1; }
[ -f "$JOURNAL" ] && . "$JOURNAL"
locate_table() {
local name="$1" site="${2:-${HCISITE:-}}"
# Strip .tbl if user gave it
name="${name%.tbl}"
for d in \
"${HCIROOT:-}/$site/tables" \
"${HCIROOT:-}/$site/Tables" \
"${HCIROOT:-}/Tables"; do
[ -f "$d/${name}.tbl" ] && { printf '%s\n' "$d/${name}.tbl"; return 0; }
done
return 1
}
cmd_list() {
local site="${HCISITE:-}"
while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done
for d in "${HCIROOT:-}/$site/tables" "${HCIROOT:-}/$site/Tables" "${HCIROOT:-}/Tables"; do
[ -d "$d" ] || continue
find "$d" -maxdepth 1 -name '*.tbl' -type f 2>/dev/null | sort
done | sort -u
}
cmd_show() {
local name="$1"; shift
local site="${HCISITE:-}"
while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done
local f; f=$(locate_table "$name" "$site") || die "no such table: $name"
cat "$f"
}
cmd_pairs() {
local name="$1"; shift
local site="${HCISITE:-}"
local fmt="csv"
while [ $# -gt 0 ]; do case "$1" in
--site) shift; site="$1" ;;
--format) shift; fmt="$1" ;;
esac; shift; done
local f; f=$(locate_table "$name" "$site") || die "no such table: $name"
case "$fmt" in
csv) "$T2C" --with-header "$f" ;;
tsv) "$T2C" --with-header --delim $'\t' "$f" ;;
*) die "bad --format" ;;
esac
}
cmd_lookup() {
local name="$1" input="$2"
local site="${HCISITE:-}"
local f; f=$(locate_table "$name" "$site") || die "no such table: $name"
"$T2C" "$f" | awk -F',' -v target="$input" '
BEGIN { found=0 }
{
# CSV unquote (basic)
v=$1; gsub(/^"|"$/, "", v); gsub(/""/, "\"", v)
if (v == target) { o=$2; gsub(/^"|"$/, "", o); gsub(/""/, "\"", o); print o; found=1; exit }
}
END { if (!found) exit 1 }
'
}
cmd_reverse_lookup() {
local name="$1" output="$2"
local site="${HCISITE:-}"
local f; f=$(locate_table "$name" "$site") || die "no such table: $name"
"$T2C" "$f" | awk -F',' -v target="$output" '
{
v=$2; gsub(/^"|"$/, "", v); gsub(/""/, "\"", v)
if (v == target) { i=$1; gsub(/^"|"$/, "", i); gsub(/""/, "\"", i); print i }
}
'
}
# Modification helpers (journal-backed)
modify_via_csv() {
local name="$1" csv_path="$2" site="$3" mode="$4" # mode = add|delete|replace|create
local action="$mode-table"
local target
# Determine target path
if [ "$mode" = "create" ]; then
[ -n "$site" ] || die "create: --site required"
[ -d "${HCIROOT}/$site/tables" ] || mkdir -p "${HCIROOT}/$site/tables"
target="${HCIROOT}/$site/tables/${name%.tbl}.tbl"
[ ! -e "$target" ] || die "table already exists: $target (use replace if you want to overwrite)"
else
target=$(locate_table "$name" "$site") || die "no such table: $name"
fi
local new; new=$(mktemp)
"$C2T" --has-header --out "$new" < "$csv_path" 2>/dev/null \
|| "$C2T" --has-header "$csv_path" > "$new"
if declare -f journal_write >/dev/null 2>&1; then
journal_write "$target" "$new"
else
# No journal — direct write with simple backup
[ -f "$target" ] && cp -p "$target" "${target}.larry-bak.$(date +%s)"
mv "$new" "$target"
echo "(no journal available; backup at ${target}.larry-bak.<ts>)"
fi
rm -f "$new"
}
cmd_add() {
local name="$1" input="$2" output="$3"; shift 3
local site="${HCISITE:-}"
while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done
[ -n "$name" ] && [ -n "$input" ] && [ -n "$output" ] || die "usage: add NAME INPUT OUTPUT"
local f; f=$(locate_table "$name" "$site") || die "no such table: $name"
# Read current pairs, replace existing input row or append, then rewrite
local tmp_csv; tmp_csv=$(mktemp)
{
"$T2C" --with-header "$f"
# If the new pair wasn't already in the existing data, it'll be added below
} > "$tmp_csv"
local awk_script='
BEGIN { added=0 }
NR==1 { print; next }
{
n=split($0, c, ",")
v=c[1]; gsub(/^"|"$/, "", v); gsub(/""/, "\"", v)
if (v == NEW_IN) {
printf "%s,%s\n", NEW_IN, NEW_OUT
added=1
} else { print }
}
END { if (!added) printf "%s,%s\n", NEW_IN, NEW_OUT }
'
local newcsv; newcsv=$(mktemp)
awk -v NEW_IN="$input" -v NEW_OUT="$output" "$awk_script" "$tmp_csv" > "$newcsv"
modify_via_csv "$name" "$newcsv" "$site" "add"
rm -f "$tmp_csv" "$newcsv"
}
cmd_delete() {
local name="$1" input="$2"; shift 2
local site="${HCISITE:-}"
while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done
[ -n "$name" ] && [ -n "$input" ] || die "usage: delete NAME INPUT"
local f; f=$(locate_table "$name" "$site") || die "no such table: $name"
local tmp_csv; tmp_csv=$(mktemp)
"$T2C" --with-header "$f" \
| awk -F',' -v IN="$input" '
NR==1 { print; next }
{
v=$1; gsub(/^"|"$/, "", v); gsub(/""/, "\"", v)
if (v == IN) next
print
}
' > "$tmp_csv"
modify_via_csv "$name" "$tmp_csv" "$site" "delete"
rm -f "$tmp_csv"
}
cmd_create() {
local name="$1"; shift
local from_csv=""
local site="${HCISITE:-}"
while [ $# -gt 0 ]; do case "$1" in
--from-csv) shift; from_csv="$1" ;;
--site) shift; site="$1" ;;
esac; shift; done
[ -f "$from_csv" ] || die "create: --from-csv FILE required"
modify_via_csv "$name" "$from_csv" "$site" "create"
}
cmd_replace() {
local name="$1"; shift
local from_csv=""
local site="${HCISITE:-}"
while [ $# -gt 0 ]; do case "$1" in
--from-csv) shift; from_csv="$1" ;;
--site) shift; site="$1" ;;
esac; shift; done
[ -f "$from_csv" ] || die "replace: --from-csv FILE required"
modify_via_csv "$name" "$from_csv" "$site" "replace"
}
SUB="${1:-list}"
case "$SUB" in
list) shift; cmd_list "$@" ;;
show) shift; [ $# -ge 1 ] || die "usage: show NAME"; cmd_show "$@" ;;
pairs) shift; [ $# -ge 1 ] || die "usage: pairs NAME"; cmd_pairs "$@" ;;
lookup) shift; [ $# -ge 2 ] || die "usage: lookup NAME INPUT"; cmd_lookup "$@" ;;
reverse-lookup) shift; [ $# -ge 2 ] || die "usage: reverse-lookup NAME OUTPUT"; cmd_reverse_lookup "$@" ;;
add) shift; cmd_add "$@" ;;
delete) shift; cmd_delete "$@" ;;
create) shift; cmd_create "$@" ;;
replace) shift; cmd_replace "$@" ;;
help|-h|--help) sed -n '2,20p' "$NC_SELF" ;;
*) die "unknown subcommand: $SUB" ;;
esac

292
lib/nc-tclgen.sh Executable file
View File

@ -0,0 +1,292 @@
#!/usr/bin/env bash
# nc-tclgen.sh — generate TCL UPOC scaffolding from intent. Skeletons for
# common Cloverleaf TPS/Xlate use cases. Output is ready-to-edit TCL.
#
# Larry can write your full UPOC for you via prompt; this tool gives you a
# clean, lint-free starting point with the right argument handling and
# boilerplate. Useful when offline or when you want to start from a template.
#
# Subcommands:
# tps-presc <proc> [--description TXT] PreSC TPS proc skeleton
# tps-postsc <proc> [--description TXT] PostSC TPS proc skeleton
# tps-iclkill <proc> [--description TXT] a proc that calls hcitpsmsgkill
# xlate-helper <proc> [--description TXT] xlate helper function skeleton
# trxid <proc> [--description TXT] trxid (routing key) extractor
# ack <proc> [--description TXT] raw ACK generator
# field-rewrite <proc> --segment SEG --field N --to VALUE small field setter
# list-templates
#
# Output: TCL source to stdout (or --out PATH).
set -o pipefail
usage() { sed -n '2,20p' "$0"; exit 0; }
die() { printf 'nc-tclgen: %s\n' "$*" >&2; exit 1; }
header() {
local proc="$1" desc="$2" template="$3"
cat <<EOF
########################################################################
# $proc — $desc
#
# Cloverleaf UPOC: $template
# Generated by larry-anywhere nc-tclgen.sh on $(date -Iseconds 2>/dev/null || date)
# Author: ${USER:-larry-anywhere}
########################################################################
EOF
}
emit_tps_presc() {
local proc="$1" desc="${2:-PreSC handler — runs before service send}"
header "$proc" "$desc" "PreSC"
cat <<EOF
proc $proc {dArgs} {
upvar 1 \$dArgs args
keylget args MODE mode
keylget args MSGID mid
switch -- \$mode {
start { return "" }
run {
# Get the message data
set hl7Data [keylget args MSGDATA]
# TODO: inspect or transform \$hl7Data
# Example: strip CR/LF from segment delimiters
# regsub -all {\r|\n} \$hl7Data " " hl7Data
# Put the (possibly modified) message back into args
keylset args MSGDATA \$hl7Data
return { { CONTINUE } }
}
time { return "" }
shutdown { return "" }
default { return "" }
}
}
EOF
}
emit_tps_postsc() {
local proc="$1" desc="${2:-PostSC handler — runs after route translation}"
header "$proc" "$desc" "PostSC"
cat <<EOF
proc $proc {dArgs} {
upvar 1 \$dArgs args
keylget args MODE mode
keylget args MSGID mid
switch -- \$mode {
start { return "" }
run {
set hl7Data [keylget args MSGDATA]
# TODO: post-xlate processing
# Example: log message control ID
# set msh [lindex [split \$hl7Data "\r"] 0]
# set ctlId [lindex [split \$msh "|"] 9]
# echo "post-xlate \$proc: ctlId=\$ctlId"
keylset args MSGDATA \$hl7Data
return { { CONTINUE } }
}
time { return "" }
shutdown { return "" }
default { return "" }
}
}
EOF
}
emit_tps_iclkill() {
local proc="$1" desc="${2:-Drop message based on condition}"
header "$proc" "$desc" "PreSC (kills msg conditionally)"
cat <<EOF
proc $proc {dArgs} {
upvar 1 \$dArgs args
keylget args MODE mode
switch -- \$mode {
start { return "" }
run {
set hl7Data [keylget args MSGDATA]
# TODO: decide whether to drop. Return KILL to drop, CONTINUE to keep.
# Example: drop if PID-18 (account) is empty.
set segs [split \$hl7Data "\r"]
foreach s \$segs {
if {[string match "PID|*" \$s]} {
set fields [split \$s "|"]
if {[lindex \$fields 18] eq ""} {
echo "$proc: dropping message — empty PID.18"
return { { KILL } }
}
}
}
return { { CONTINUE } }
}
default { return "" }
}
}
EOF
}
emit_xlate_helper() {
local proc="$1" desc="${2:-Xlate helper function}"
header "$proc" "$desc" "Xlate proc"
cat <<EOF
# Xlate-callable helper. Use in an Xlate via:
# { OP COPY } { IN =[proc $proc {input}] } { OUT 0(0).PID(0).#X }
proc $proc {input} {
# TODO: transform \$input into the desired output value.
# Examples:
# - Uppercase: return [string toupper \$input]
# - Default fallback: if {\$input eq ""} { return "UNKNOWN" } else { return \$input }
# - Table lookup: return [tablelookup my_table_name \$input]
return \$input
}
EOF
}
emit_trxid() {
local proc="$1" desc="${2:-Routing-key extractor (returns trxid for routes)}"
header "$proc" "$desc" "TRXID (DATAFORMAT.PROC)"
cat <<EOF
proc $proc {dArgs} {
upvar 1 \$dArgs args
keylget args MSGDATA hl7Data
# TODO: return a string that route-conditions will match (TRXID regex).
# Common pattern: combine MSH.9 event + a tag from PID/PV1.
set segs [split \$hl7Data "\r"]
set msh [lindex \$segs 0]
set mshFields [split \$msh "|"]
set msgType [lindex \$mshFields 8] ;# MSH.9 (ADT^A08)
# Example: include receiving facility (PV1.4) or patient class
set trxid "\$msgType"
keylset args TRXID \$trxid
return { { CONTINUE } }
}
EOF
}
emit_ack() {
local proc="$1" desc="${2:-Generate a raw ACK (AA) for the inbound message}"
header "$proc" "$desc" "ACK generator"
cat <<EOF
proc $proc {dArgs} {
upvar 1 \$dArgs args
keylget args MSGDATA hl7Data
set segs [split \$hl7Data "\r"]
set msh [lindex \$segs 0]
set mshFields [split \$msh "|"]
set sendingApp [lindex \$mshFields 2]
set sendingFac [lindex \$mshFields 3]
set receivingApp [lindex \$mshFields 4]
set receivingFac [lindex \$mshFields 5]
set ctlId [lindex \$mshFields 9]
set ts [clock format [clock seconds] -format "%Y%m%d%H%M%S"]
set ackMsh "MSH|^~\\\\&|\$receivingApp|\$receivingFac|\$sendingApp|\$sendingFac|\$ts||ACK|\$ctlId|P|2.3"
set ackMsa "MSA|AA|\$ctlId"
set ack "\$ackMsh\r\$ackMsa\r"
keylset args MSGDATA \$ack
return { { CONTINUE } }
}
EOF
}
emit_field_rewrite() {
local proc="$1" seg="$2" fnum="$3" to="$4"
local desc="Set $seg.$fnum to '$to' on every message"
header "$proc" "$desc" "PreSC field rewrite"
cat <<EOF
proc $proc {dArgs} {
upvar 1 \$dArgs args
keylget args MODE mode
if {\$mode ne "run"} { return "" }
set hl7Data [keylget args MSGDATA]
set segs [split \$hl7Data "\r"]
set newSegs {}
foreach s \$segs {
if {[string match "$seg|*" \$s]} {
set fields [split \$s "|"]
# $seg.\$fnum maps to list-index N (HL7 segment numbering)
set fields [lreplace \$fields $fnum $fnum "$to"]
set s [join \$fields "|"]
}
lappend newSegs \$s
}
keylset args MSGDATA [join \$newSegs "\r"]
return { { CONTINUE } }
}
EOF
}
list_templates() {
cat <<EOF
Templates:
tps-presc PreSC handler skeleton
tps-postsc PostSC handler skeleton
tps-iclkill Conditional message drop
xlate-helper Xlate-callable helper function
trxid Routing key extractor
ack Raw ACK generator
field-rewrite Set a specific field to a constant on every message
EOF
}
SUB="${1:-help}"
shift 2>/dev/null
OUT_FILE=""
PROC=""
DESC=""
SEG=""; FNUM=""; TO=""
# Parse common flags
while [ $# -gt 0 ]; do
case "$1" in
--description) shift; DESC="$1" ;;
--out) shift; OUT_FILE="$1" ;;
--segment) shift; SEG="$1" ;;
--field) shift; FNUM="$1" ;;
--to) shift; TO="$1" ;;
-h|--help) usage ;;
-*) die "unknown flag: $1" ;;
*) [ -z "$PROC" ] && PROC="$1" || die "extra arg: $1" ;;
esac
shift
done
run_emit() {
if [ -n "$OUT_FILE" ]; then
mkdir -p "$(dirname "$OUT_FILE")" 2>/dev/null
"$@" > "$OUT_FILE"
printf 'wrote %s\n' "$OUT_FILE" >&2
else
"$@"
fi
}
case "$SUB" in
tps-presc) [ -n "$PROC" ] || die "needs PROC name"; run_emit emit_tps_presc "$PROC" "$DESC" ;;
tps-postsc) [ -n "$PROC" ] || die "needs PROC name"; run_emit emit_tps_postsc "$PROC" "$DESC" ;;
tps-iclkill) [ -n "$PROC" ] || die "needs PROC name"; run_emit emit_tps_iclkill "$PROC" "$DESC" ;;
xlate-helper) [ -n "$PROC" ] || die "needs PROC name"; run_emit emit_xlate_helper "$PROC" "$DESC" ;;
trxid) [ -n "$PROC" ] || die "needs PROC name"; run_emit emit_trxid "$PROC" "$DESC" ;;
ack) [ -n "$PROC" ] || die "needs PROC name"; run_emit emit_ack "$PROC" "$DESC" ;;
field-rewrite) [ -n "$PROC" ] && [ -n "$SEG" ] && [ -n "$FNUM" ] && [ -n "$TO" ] \
|| die "needs PROC + --segment SEG --field N --to VALUE"
run_emit emit_field_rewrite "$PROC" "$SEG" "$FNUM" "$TO" ;;
list-templates|list) list_templates ;;
help|-h|--help) usage ;;
*) die "unknown subcommand: $SUB" ;;
esac

155
lib/nc-xlate.sh Executable file
View File

@ -0,0 +1,155 @@
#!/usr/bin/env bash
# nc-xlate.sh — visualize and explore Cloverleaf xlate (.xlt) files.
# Parses the TCL nested-block format and renders operation flows.
#
# Subcommands:
# list [--site SITE] list .xlt files
# show <name> [--site SITE] dump the raw file
# ops <name> [--site SITE] list operations as TSV (op, in, out, err)
# tree <name> [--site SITE] ASCII tree by op type
# summary <name> [--site SITE] counts by operation + segments touched
# diff <name1> <name2> diff two xlates (semantic, sorted-by-op)
set -o pipefail
NC_SELF="$0"
die() { printf 'nc-xlate: %s\n' "$*" >&2; exit 1; }
locate_xlate() {
local name="$1" site="${2:-${HCISITE:-}}"
name="${name%.xlt}"
for d in "${HCIROOT:-}/$site/Xlate" "${HCIROOT:-}/$site/xlate" "${HCIROOT:-}/Xlate"; do
[ -f "$d/${name}.xlt" ] && { printf '%s\n' "$d/${name}.xlt"; return 0; }
done
return 1
}
cmd_list() {
local site="${HCISITE:-}"
while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done
for d in "${HCIROOT:-}/$site/Xlate" "${HCIROOT:-}/$site/xlate"; do
[ -d "$d" ] || continue
find "$d" -maxdepth 1 -name '*.xlt' -type f 2>/dev/null | sort
done
}
cmd_show() {
local name="$1"; shift
local site="${HCISITE:-}"
while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done
local f; f=$(locate_xlate "$name" "$site") || die "no such xlate: $name"
cat "$f"
}
# Parse the .xlt operations. Each top-level block looks like:
# { { OP <name> }
# { ERR <code> }
# { IN <path> }
# { OUT <path> }
# [ { TABLE <name> } ]
# [ ... ]
# }
# Emit TSV: op_num \t op \t in \t out \t err \t extra
parse_ops() {
local f="$1"
awk '
BEGIN { depth=0; in_block=0; n=0 }
/^end_prologue$/ { in_pre=0; next }
/^prologue$/ { in_pre=1; next }
in_pre { next }
/^[[:space:]]*\{ \{ OP / {
depth=1; in_block=1
op=""; in_path=""; out_path=""; err=""; extra=""
# First line has "{ { OP <name> }"
match($0, /\{ OP [A-Z]+ \}/)
if (RSTART) {
op = substr($0, RSTART+5, RLENGTH-7)
}
next
}
in_block {
# Track depth via gsub-of-self trick
no = gsub(/\{/, "{", $0)
nc = gsub(/\}/, "}", $0)
depth += no - nc
if (match($0, /\{ IN [^}]+\}/)) { in_path = substr($0, RSTART+5, RLENGTH-6); gsub(/[[:space:]]+$/, "", in_path) }
if (match($0, /\{ OUT [^}]+\}/)) { out_path = substr($0, RSTART+6, RLENGTH-7); gsub(/[[:space:]]+$/, "", out_path) }
if (match($0, /\{ ERR [^}]+\}/)) { err = substr($0, RSTART+6, RLENGTH-7); gsub(/[[:space:]]+$/, "", err) }
if (match($0, /\{ TABLE [^}]+\}/)) { extra = "TABLE=" substr($0, RSTART+8, RLENGTH-9); gsub(/[[:space:]]+$/, "", extra) }
if (depth == 0) {
n++
printf "%d\t%s\t%s\t%s\t%s\t%s\n", n, op, in_path, out_path, err, extra
in_block = 0
}
}
' "$f"
}
cmd_ops() {
local name="$1"; shift
local site="${HCISITE:-}"
while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done
local f; f=$(locate_xlate "$name" "$site") || die "no such xlate: $name"
printf 'num\top\tin\tout\terr\textra\n'
parse_ops "$f"
}
cmd_tree() {
local name="$1"; shift
local site="${HCISITE:-}"
while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done
local f; f=$(locate_xlate "$name" "$site") || die "no such xlate: $name"
printf 'Xlate: %s\n' "$name"
parse_ops "$f" | awk -F'\t' '
{
op=$2; in_path=$3; out_path=$4; extra=$6
if (in_path == "" && out_path == "") { printf " %d. %s\n", $1, op; next }
arrow = "→"
if (op == "MOVE") arrow = "↦"
if (op == "DELETE") arrow = "✗"
if (extra != "") arrow = "↦(" extra ")→"
printf " %2d. %-12s %-30s %s %s\n", $1, op, in_path, arrow, out_path
}
'
}
cmd_summary() {
local name="$1"; shift
local site="${HCISITE:-}"
while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done
local f; f=$(locate_xlate "$name" "$site") || die "no such xlate: $name"
printf 'Xlate: %s (path: %s)\n\n' "$name" "$f"
printf 'Operations by type:\n'
parse_ops "$f" | awk -F'\t' 'NR>0 {c[$2]++} END {for (k in c) printf " %-12s %d\n", k, c[k]}' | sort -k2 -rn
printf '\nSegments touched (IN side):\n'
parse_ops "$f" | awk -F'\t' '{
if (match($3, /\.[A-Z][A-Z0-9]+\(/)) {
seg = substr($3, RSTART+1, RLENGTH-2)
c[seg]++
}
} END {for (k in c) printf " %-6s %d\n", k, c[k]}' | sort
printf '\nTables referenced:\n'
parse_ops "$f" | awk -F'\t' '$6 ~ /TABLE=/ { t=substr($6,7); print t }' | sort -u | sed 's/^/ /'
}
cmd_diff() {
local n1="$1" n2="$2"
local f1 f2
f1=$(locate_xlate "$n1") || die "no such xlate: $n1"
f2=$(locate_xlate "$n2") || die "no such xlate: $n2"
diff -u <(parse_ops "$f1" | sort -k2) <(parse_ops "$f2" | sort -k2)
}
SUB="${1:-list}"
case "$SUB" in
list) shift; cmd_list "$@" ;;
show) shift; [ $# -ge 1 ] || die "usage: show NAME"; cmd_show "$@" ;;
ops) shift; [ $# -ge 1 ] || die "usage: ops NAME"; cmd_ops "$@" ;;
tree) shift; [ $# -ge 1 ] || die "usage: tree NAME"; cmd_tree "$@" ;;
summary) shift; [ $# -ge 1 ] || die "usage: summary NAME"; cmd_summary "$@" ;;
diff) shift; [ $# -ge 2 ] || die "usage: diff NAME1 NAME2"; cmd_diff "$@" ;;
help|-h|--help) sed -n '2,15p' "$NC_SELF" ;;
*) die "unknown subcommand: $SUB" ;;
esac