From 05430e34735749b386e90281de6f0c92b2403c09 Mon Sep 17 00:00:00 2001 From: SELAT-DEV <290539170+SELAT-DEV@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:22:04 -0700 Subject: [PATCH] feat(run): pass quote.transactabilityTrace through to --dry-run --json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dry run with --live-probe already spawns selat-pay --probe-only once for the one-line Transactability Score; that probe's stdout IS selat-pay's --probe-only JSON, whose quote.transactabilityTrace carries the full attribution-typed decision trace. Harvest both halves from the single spawn and, under --json, include the trace as a transactabilityTrace field in the dry-run object — so machine consumers (and the docs decision-trace / first-party pages) can use `selat run` instead of the internal selat-pay CLI. Human (non-json) output is unchanged: the score line only. The trace is passed through opaquely (shape owned by selat-pay); absent, non-JSON, or trace-less probe output omits the field rather than failing the dry run. Co-Authored-By: Claude Fable 5 --- lib/commands/run.mjs | 46 ++++++++++++++++----- test/run-transactability.test.mjs | 69 +++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/lib/commands/run.mjs b/lib/commands/run.mjs index 67a44b5..c5dedea 100644 --- a/lib/commands/run.mjs +++ b/lib/commands/run.mjs @@ -440,8 +440,13 @@ export async function run(args) { // reading the paid receipt shows, before any spend. Without --live-probe we // never touch the network, so no score is shown. Best-effort: a probe // failure omits the line rather than breaking the dry run. - const transactability = liveProbe - ? await probeTransactabilityLine(selatPay, selatPayArgs) + // + // The same probe's stdout is selat-pay's --probe-only JSON, whose + // quote.transactabilityTrace is the full attribution-typed decision trace. + // Under --json we pass it through so machine consumers get the trace from + // `selat run` without reaching for the internal selat-pay CLI. + const probe = liveProbe + ? await probeTransactability(selatPay, selatPayArgs) : null; return printDryRun({ intent, @@ -449,7 +454,10 @@ export async function run(args) { command: display, exec: payExecTuple(selatPay, selatPayArgs), jsonMode, - extra: transactability ? { transactability } : {}, + extra: { + ...(probe?.transactability ? { transactability: probe.transactability } : {}), + ...(jsonMode && probe?.transactabilityTrace ? { transactabilityTrace: probe.transactabilityTrace } : {}), + }, }); } @@ -639,18 +647,36 @@ export function probeArgvFromPayArgs(payArgs) { return out; } -// Best-effort Transactability Score for the --dry-run preview: run the pick -// through selat-pay --probe-only (no settlement) and format the score from the -// same captured stderr the paid receipt parses. Returns the one-line string, or -// null on any failure — a probe hiccup must never break a dry run. -async function probeTransactabilityLine(selatPay, selatPayArgs) { +// Parse `quote.transactabilityTrace` out of captured selat-pay --probe-only +// stdout (the probe's stdout IS one JSON object). Returns the trace object, or +// null when the output is absent, non-JSON, or carries no trace — all of which +// downstream means "no trace field", never an error. Pure + exported so the +// parsing is unit-testable without spawning. +export function transactabilityTraceFromStdout(stdout) { + if (!stdout || typeof stdout !== "string") return null; + let parsed; + try { parsed = JSON.parse(stdout); } catch { return null; } + const trace = parsed?.quote?.transactabilityTrace; + return (trace && typeof trace === "object") ? trace : null; +} + +// Best-effort Transactability reading for the --dry-run preview: run the pick +// through selat-pay --probe-only (no settlement) ONCE and harvest both halves +// of its output — the one-line score from captured stderr (same line the paid +// receipt parses) and the machine-readable quote.transactabilityTrace from its +// JSON stdout. Either field is null on any failure — a probe hiccup must never +// break a dry run. +async function probeTransactability(selatPay, selatPayArgs) { try { const { cmd, args } = selatPaySpawn(selatPay, probeArgvFromPayArgs(selatPayArgs)); await ensureSelatPayHistoryDir(); const probe = await sh(cmd, args, { inherit: false }); - return transactabilityLineFromStderr(probe.stderr); + return { + transactability: transactabilityLineFromStderr(probe.stderr), + transactabilityTrace: transactabilityTraceFromStdout(probe.stdout), + }; } catch { - return null; + return { transactability: null, transactabilityTrace: null }; } } diff --git a/test/run-transactability.test.mjs b/test/run-transactability.test.mjs index 4062d0b..1ab0970 100644 --- a/test/run-transactability.test.mjs +++ b/test/run-transactability.test.mjs @@ -84,3 +84,72 @@ test("probeArgvFromPayArgs preserves the endpoint/method/body and never double-a assert.equal(probe[0], "GET"); assert.equal(probe[1], "https://x/y"); }); + +// --- trace passthrough: quote.transactabilityTrace from probe stdout ---------- +// +// `selat run --dry-run --live-probe --json` passes the full decision trace +// through from selat-pay's --probe-only JSON stdout, so machine consumers get +// it without reaching for the internal selat-pay CLI. These pin the stdout +// parsing seam. DISPLAY/telemetry only — nothing gates on the trace. + +import { transactabilityTraceFromStdout } from "../lib/commands/run.mjs"; + +// A representative --probe-only stdout: one JSON object whose quote carries +// the attribution-typed trace (shape owned by selat-pay's +// buildTransactabilityTrace; we pass it through opaquely). +const probeStdoutWith = (trace) => + JSON.stringify( + { + mode: "routed", + selectedProtocol: "x402", + detected: { protocols: ["x402"] }, + quote: { + quoteId: "q_123", + price: { amount: "15000", formatted: "$0.015000 USDC" }, + network: "base", + payTo: "0xabc", + scheme: "exact", + ...(trace !== undefined ? { transactabilityTrace: trace } : {}), + }, + }, + null, + 2 + ) + "\n"; + +const SAMPLE_TRACE = { + metric: "transactability", + version: "1", + endpointUrl: "https://api.exa.ai/search", + dataStatus: "measured", + attribution: { + counterparty: { + owner: "endpoint", + primarySource: "network", + signal: "ok", + network: { window: "7d", deliveryRate: 0.98, capturedPayments: 42, scope: "network-wide" }, + }, + }, +}; + +test("parses quote.transactabilityTrace out of --probe-only stdout", () => { + const trace = transactabilityTraceFromStdout(probeStdoutWith(SAMPLE_TRACE)); + assert.ok(trace, "expected a trace object"); + // Passthrough is opaque: the object comes back exactly as selat-pay emitted it. + assert.deepEqual(trace, SAMPLE_TRACE); +}); + +test("a quote without a trace yields null, not an error", () => { + assert.equal(transactabilityTraceFromStdout(probeStdoutWith(undefined)), null); +}); + +test("non-JSON, empty, or missing stdout yields null", () => { + assert.equal(transactabilityTraceFromStdout("routed free passthrough\n"), null); + assert.equal(transactabilityTraceFromStdout(""), null); + assert.equal(transactabilityTraceFromStdout(undefined), null); + assert.equal(transactabilityTraceFromStdout(null), null); +}); + +test("a non-object trace value is rejected, not passed through", () => { + assert.equal(transactabilityTraceFromStdout(probeStdoutWith("measured")), null); + assert.equal(transactabilityTraceFromStdout(probeStdoutWith(42)), null); +});