Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions lib/commands/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -440,16 +440,24 @@ 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,
plan,
command: display,
exec: payExecTuple(selatPay, selatPayArgs),
jsonMode,
extra: transactability ? { transactability } : {},
extra: {
...(probe?.transactability ? { transactability: probe.transactability } : {}),
...(jsonMode && probe?.transactabilityTrace ? { transactabilityTrace: probe.transactabilityTrace } : {}),
},
});
}

Expand Down Expand Up @@ -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 };
}
}

Expand Down
69 changes: 69 additions & 0 deletions test/run-transactability.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});