feat(flow-debugger): outbound calls on the External lane — CLI projection, specs, rig demo + verification step I
CLI flow-debug table now projects through FlowGraphBuilder (one row per step, outbound lifecycles folded, new 'lifecycle' column; per-event fallback kept). Specs: Component-CentralUI (four lanes, node anatomy, pending refresh), Component-AuditLog (central method-script Database.* now audited; replay reads the three outbound channels), Component-InboundAPI (DbOutbound row per helper call), Component-ScriptAnalysis (REQ-SA-7 kinds + Async edges), design doc v1.1 status line, CLAUDE.md bullet, deferred-work register row 111 (Retry/ Discard + ?correlationId= deep links deferred). Rig: seed-flow-outbound-demo.sh (RigRest/RigDb/FlowDemo + OutboundDemo script + FlowOutboundDemo method/key) and verify-delmia-notifier.sh step I (live rows inline, replay until terminal). Rig verification result follows in its own commit. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
+2
-1
@@ -99,7 +99,8 @@ docker/
|
||||
├── seed-live-probe.sh # Seed one deployable live-data instance on site-a (stream traffic)
|
||||
├── seed-delmia-sim.sh # Seed the in-memory Delmia machine stand-in (DelmiaSim01) on site-a
|
||||
├── seed-delmia-api.sh # Seed the DelmiaRecipeDownload inbound method + a scoped API key (token printed once)
|
||||
├── verify-delmia-notifier.sh # DelmiaNotifier end-to-end vs the rig: standby relay, active-central crash/takeover, rejoin; step H = Flow Debugger live run + replay (CLI flow-debug)
|
||||
├── seed-flow-outbound-demo.sh # Seed the Flow Debugger outbound-calls demo: RigRest external system, RigDb connection, FlowDemo list, OutboundDemo script + FlowOutboundDemo method/key
|
||||
├── verify-delmia-notifier.sh # DelmiaNotifier end-to-end vs the rig: standby relay, active-central crash/takeover, rejoin; step H = Flow Debugger live run + replay (CLI flow-debug); step I = outbound API/DB/notify nodes live + replay
|
||||
├── teardown.sh # Stop and remove containers
|
||||
├── central-node-a/
|
||||
│ ├── appsettings.Central.json # Central node A configuration
|
||||
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Seed the Flow Debugger OUTBOUND-CALLS demo on the docker rig (plan
|
||||
# docs/plans/2026-09-06-flow-debugger-outbound-calls.md, Task 10): one site Call script that makes
|
||||
# all three outbound call kinds, plus an inbound method routing to it, so `flow-debug run-method` /
|
||||
# the /deployment/flow-debugger page show ApiCall + DbCall + Notify nodes on the External lane.
|
||||
#
|
||||
# What it creates:
|
||||
# - External system RigRest -> the infra REST API (scadabridge-restapi:5200, X-API-Key
|
||||
# scadabridge-test-key-1) with one method Echo = POST /api/Echo.
|
||||
# - DB connection RigDb -> the rig's ScadaBridgeMachineData database on scadabridge-mssql.
|
||||
# - Notification list FlowDemo (email; delivery needs an SMTP configuration row — create one on
|
||||
# /notifications/smtp pointing at scadabridge-smtp:1025, auth None —
|
||||
# otherwise the notification node ends Parked, which is itself a
|
||||
# valid terminal lifecycle for the demo).
|
||||
# - Script OutboundDemo (Call) on template SimMachine (docker/seed-delmia-sim.sh):
|
||||
# ExternalSystem.Call("RigRest","Echo") -> sync ApiCall, in-chain
|
||||
# Database.CachedWrite("RigDb", …) -> DbCall, async (creates + inserts dbo.FlowDemo)
|
||||
# Notify.To("FlowDemo").Send(…) -> Notify, async
|
||||
# - Inbound method FlowOutboundDemo (Route.To(MachineCode).Call("OutboundDemo")) + an API key
|
||||
# scoped to it (token printed once as FLOW_DEMO_API_KEY=…).
|
||||
# - Artifacts pushed to site 1 and DelmiaSim01 redeployed so the site has the new script.
|
||||
#
|
||||
# Not idempotent (names must be unique). Prerequisites: docker/deploy.sh, seed-sites.sh,
|
||||
# seed-delmia-sim.sh, and `infra/` up (restapi + mssql).
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
|
||||
A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
|
||||
SITE_ID="${SITE_ID:-1}"
|
||||
INSTANCE_NAME="${INSTANCE_NAME:-DelmiaSim01}"
|
||||
TEMPLATE_NAME="${TEMPLATE_NAME:-SimMachine}"
|
||||
KEY_NAME="${KEY_NAME:-flow-outbound-demo}"
|
||||
RIG_DB_CS="${RIG_DB_CS:-Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true}"
|
||||
|
||||
json_id() { python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'; }
|
||||
find_id() { # $1 = json array on stdin key name, $2 = value
|
||||
python3 -c 'import json,sys; k,v=sys.argv[1],sys.argv[2]; print(next(x["id"] for x in json.load(sys.stdin) if x.get(k)==v))' "$1" "$2"; }
|
||||
|
||||
echo "1/7 External system RigRest -> scadabridge-restapi (method Echo = POST /api/Echo)..."
|
||||
ES_ID=$("$CLI" external-system create "${A[@]}" --name RigRest --endpoint-url http://scadabridge-restapi:5200 \
|
||||
--auth-type ApiKey --auth-config '{"key":"scadabridge-test-key-1"}' --timeout-seconds 10 | json_id)
|
||||
"$CLI" external-system method create "${A[@]}" --external-system-id "$ES_ID" --name Echo --http-method POST --path /api/Echo > /dev/null
|
||||
echo " id=$ES_ID"
|
||||
|
||||
echo "2/7 DB connection RigDb -> scadabridge-mssql/ScadaBridgeMachineData..."
|
||||
DB_ID=$("$CLI" db-connection create "${A[@]}" --name RigDb --connection-string "$RIG_DB_CS" | json_id)
|
||||
echo " id=$DB_ID"
|
||||
|
||||
echo "3/7 Notification list FlowDemo..."
|
||||
LIST_ID=$("$CLI" notification create "${A[@]}" --name FlowDemo --emails flow-demo@rig.local | json_id)
|
||||
echo " id=$LIST_ID"
|
||||
|
||||
echo "4/7 Script OutboundDemo (Call) on template $TEMPLATE_NAME..."
|
||||
TEMPLATE_ID=$("$CLI" template list "${A[@]}" | find_id name "$TEMPLATE_NAME")
|
||||
read -r -d '' DEMO_CODE <<'CS' || true
|
||||
// Flow Debugger outbound-calls demo: one sync API call (in-chain), one cached DB write and one
|
||||
// notification (both async branches). Each is an ordinary audited call — nothing here is
|
||||
// debugger-specific; the Flow Debugger just draws the rows they leave.
|
||||
System.Func<string,object?> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
|
||||
var wo = P("WorkOrder")?.ToString() ?? "WO-0";
|
||||
|
||||
var echo = await ExternalSystem.Call("RigRest", "Echo",
|
||||
new System.Collections.Generic.Dictionary<string, object?> { ["workOrder"] = wo, ["source"] = "OutboundDemo" });
|
||||
|
||||
var db = await Database.CachedWrite("RigDb",
|
||||
"IF OBJECT_ID('dbo.FlowDemo','U') IS NULL CREATE TABLE dbo.FlowDemo (Id INT IDENTITY(1,1) PRIMARY KEY, WorkOrder NVARCHAR(64) NOT NULL, At DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()); INSERT INTO dbo.FlowDemo (WorkOrder) VALUES (@wo);",
|
||||
new System.Collections.Generic.Dictionary<string, object?> { ["wo"] = wo });
|
||||
|
||||
var notification = await Notify.To("FlowDemo").Send("Flow demo " + wo, "Echo ok=" + echo.Success + "; db op=" + db.Value);
|
||||
|
||||
return new { Result = echo.Success, ResultText = "echo=" + echo.Success + " db=" + db.Value + " notify=" + notification };
|
||||
CS
|
||||
"$CLI" template script add "${A[@]}" --template-id "$TEMPLATE_ID" --name OutboundDemo --trigger-type Call --code "$DEMO_CODE" > /dev/null
|
||||
echo " template id=$TEMPLATE_ID"
|
||||
|
||||
echo "5/7 Inbound method FlowOutboundDemo + API key '$KEY_NAME'..."
|
||||
read -r -d '' METHOD_CODE <<'CS' || true
|
||||
System.Func<string, object> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
|
||||
var code = (P("MachineCode") as string ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(code)) return new { Result = false, ResultText = "MachineCode is required" };
|
||||
return await Route.To(code).Call("OutboundDemo", new { WorkOrder = P("WorkOrder") });
|
||||
CS
|
||||
METHOD_ID=$("$CLI" api-method create "${A[@]}" --name FlowOutboundDemo --timeout 45 \
|
||||
--parameters '{"type":"object","properties":{"MachineCode":{"type":"string"},"WorkOrder":{"type":"string"}},"required":["MachineCode"]}' \
|
||||
--return-def '{"type":"object","properties":{"Result":{"type":"boolean"},"ResultText":{"type":"string"}},"required":["Result","ResultText"]}' \
|
||||
--script "$METHOD_CODE" | json_id)
|
||||
KEY_JSON=$("$CLI" security api-key create "${A[@]}" --name "$KEY_NAME" --methods FlowOutboundDemo)
|
||||
TOKEN=$(echo "$KEY_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])' 2>/dev/null || echo "$KEY_JSON" | grep -o 'sbk_[A-Za-z0-9_-]*' | head -1)
|
||||
echo " method id=$METHOD_ID"
|
||||
|
||||
echo "6/7 Pushing artifacts (external system + DB connection) to site $SITE_ID..."
|
||||
"$CLI" site deploy-artifacts "${A[@]}" --site-id "$SITE_ID" > /dev/null
|
||||
|
||||
echo "7/7 Redeploying $INSTANCE_NAME so the site has OutboundDemo..."
|
||||
INSTANCE_ID=$("$CLI" instance list "${A[@]}" | find_id uniqueName "$INSTANCE_NAME")
|
||||
"$CLI" instance deploy "${A[@]}" --id "$INSTANCE_ID" > /dev/null
|
||||
echo " instance id=$INSTANCE_ID"
|
||||
|
||||
echo
|
||||
echo "FLOW_DEMO_API_KEY=$TOKEN"
|
||||
echo "Try: scadabridge --url http://localhost:9000 flow-debug run-method --method-id $METHOD_ID --params '{\"MachineCode\":\"$INSTANCE_NAME\",\"WorkOrder\":\"WO-1\"}'"
|
||||
echo " then open /deployment/flow-debugger?executionId=<rootExecutionId>"
|
||||
@@ -31,6 +31,10 @@ set -euo pipefail
|
||||
# G reject path (DownloadPath *.bad) -> NO + "Simulated reject"
|
||||
# H Flow Debugger live run + replay -> YES (flow-debug run-method returns the traced
|
||||
# site steps inline; flow-debug replay rebuilds the same chain from the central audit store)
|
||||
# I Flow Debugger OUTBOUND calls (2026-09-06) -> YES (FlowOutboundDemo — seed-flow-outbound-demo.sh —
|
||||
# returns ApiOutbound + DbOutbound + Notification rows inline, the two async ones still Submitted;
|
||||
# replay is retried <= 90 s until both have reached a terminal lifecycle row). Skipped with a
|
||||
# note when the demo method is not seeded.
|
||||
|
||||
: "${DELMIA_API_KEY:?export DELMIA_API_KEY=sbk_... first}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"; PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -131,4 +135,59 @@ done
|
||||
[ "${H_RRC:-1}" -eq 0 ] || { echo "FAIL: replay never showed the full chain after 40 s: $H_REPLAY"; exit 1; }
|
||||
echo " replay: $H_REPLAY"
|
||||
echo "H live+replay -> YES"
|
||||
|
||||
# I — Flow Debugger outbound calls (plan 2026-09-06). The demo script makes a sync ExternalSystem.Call,
|
||||
# a Database.CachedWrite and a Notify.Send. Live: the site returns the rows it emitted inline — the
|
||||
# sync ApiCall is already Delivered, the cached write and the notification are Submitted (pending).
|
||||
# Replay: the S&F forwarder (site) and the Notification Outbox (central) write the later lifecycle
|
||||
# rows minutes apart, so replay is retried until both operations have a terminal row.
|
||||
echo "== I Flow Debugger outbound calls (live + replay)"
|
||||
I_METHOD_ID=$("$CLI" api-method list "${H_ARGS[@]}" | python3 -c 'import json,sys; ms=json.load(sys.stdin); print(next((m["id"] for m in ms if m["name"]=="FlowOutboundDemo"), ""))')
|
||||
if [ -z "$I_METHOD_ID" ]; then
|
||||
echo "I outbound live+replay -> SKIPPED (FlowOutboundDemo not seeded — run docker/seed-flow-outbound-demo.sh)"
|
||||
else
|
||||
i_check() { # $1 = live|replay; stdin = FlowDebugRunResult JSON
|
||||
python3 -c '
|
||||
import json, sys
|
||||
mode = sys.argv[1]
|
||||
r = json.load(sys.stdin)
|
||||
rows = [json.loads(e["detailsJson"]) for e in r.get("events", [])]
|
||||
by = lambda ch: [d for d in rows if d.get("channel") == ch]
|
||||
api, db, nt = by("ApiOutbound"), by("DbOutbound"), by("Notification")
|
||||
terminal = {"Delivered", "Failed", "Parked", "Discarded"}
|
||||
last = lambda ds: (ds[-1].get("status") if ds else None)
|
||||
problems = []
|
||||
if r.get("completed") is not True: problems.append("incomplete")
|
||||
if not any(d.get("kind") == "ApiCall" and d.get("status") == "Delivered" for d in api): problems.append("no Delivered ApiCall")
|
||||
if not db: problems.append("no DbOutbound rows")
|
||||
if not nt: problems.append("no Notification rows")
|
||||
if mode == "live":
|
||||
if last(db) != "Submitted": problems.append("db not pending inline")
|
||||
if last(nt) != "Submitted": problems.append("notify not pending inline")
|
||||
else:
|
||||
if last(db) not in terminal: problems.append("db still " + str(last(db)))
|
||||
if last(nt) not in terminal: problems.append("notify still " + str(last(nt)))
|
||||
if last(db) != "Delivered": problems.append("db ended " + str(last(db)))
|
||||
ok = not problems
|
||||
print(("YES" if ok else "NO") + " root=" + str(r.get("rootExecutionId")) + " events=" + str(len(rows))
|
||||
+ " api=" + str(last(api)) + " db=" + str(last(db)) + " notify=" + str(last(nt))
|
||||
+ (" problems=" + ";".join(problems) if problems else "") + (" error=" + str(r.get("errorCode")) if r.get("errorCode") else ""))
|
||||
sys.exit(0 if ok else 1)' "$1"
|
||||
}
|
||||
I_PARAMS=$(python3 -c 'import json,sys; print(json.dumps({"MachineCode":sys.argv[1],"WorkOrder":"WO-I"}))' "$MACHINE")
|
||||
set +e; I_RUN=$("$CLI" flow-debug run-method --method-id "$I_METHOD_ID" --params "$I_PARAMS" "${H_ARGS[@]}"); I_RC=$?; set -e
|
||||
[ "$I_RC" -eq 0 ] || { echo "FAIL: flow-debug run-method exited $I_RC: $I_RUN"; exit 1; }
|
||||
I_LIVE=$(echo "$I_RUN" | i_check live) || { echo "FAIL: live run: $I_LIVE"; exit 1; }
|
||||
echo " live: $I_LIVE"
|
||||
I_ROOT=$(echo "$I_RUN" | python3 -c 'import json,sys; print(json.load(sys.stdin)["rootExecutionId"])')
|
||||
I_REPLAY=""
|
||||
for i in $(seq 1 45); do
|
||||
set +e; I_REPLAY=$("$CLI" flow-debug replay --execution-id "$I_ROOT" "${H_ARGS[@]}" | i_check replay); I_RRC=$?; set -e
|
||||
[ "$I_RRC" -eq 0 ] && break
|
||||
sleep 2
|
||||
done
|
||||
[ "${I_RRC:-1}" -eq 0 ] || { echo "FAIL: replay never showed both outbound operations terminal after 90 s: $I_REPLAY"; exit 1; }
|
||||
echo " replay: $I_REPLAY"
|
||||
echo "I outbound live+replay -> YES"
|
||||
fi
|
||||
echo "== done. Notifier log: $RUN_DIR/delmia-notifier.log"
|
||||
|
||||
Reference in New Issue
Block a user