From 9f98c3c97194226d62c44acdb1d62f3cb3517d4f Mon Sep 17 00:00:00 2001 From: Enrico Piovesan Date: Tue, 11 Aug 2026 18:54:00 -0600 Subject: [PATCH] feat: own browser-consumer adapter client (Specs 001/002) Vendor the browser adapter client into browser-consumer, expose Spec 001 presentationState, and point live smoke at browser-adapter directly. Co-authored-by: Cursor --- apps/browser-consumer/README.md | 19 +- apps/browser-consumer/index.js | 45 ++- .../src/browser-adapter-client.js | 330 ++++++++++++++++++ scripts/ci/browser_consumer_package_smoke.sh | 36 +- 4 files changed, 397 insertions(+), 33 deletions(-) create mode 100644 apps/browser-consumer/src/browser-adapter-client.js diff --git a/apps/browser-consumer/README.md b/apps/browser-consumer/README.md index b7ef0b4..9fa09ba 100644 --- a/apps/browser-consumer/README.md +++ b/apps/browser-consumer/README.md @@ -4,7 +4,22 @@ Browser-targeted consumer façade for downstream apps such as `youaskm3`. Canonical home: **`traverse-framework/reference-apps`** (`apps/browser-consumer/`). -It reuses the approved live browser adapter client from [`apps/react-demo/`](../react-demo/) and exposes a browser-safe subscription flow. Runtime ordering, trace visibility, and terminal outcomes come from Traverse public surfaces — not private app logic. +It owns a copy of the approved live browser adapter client under +[`src/browser-adapter-client.js`](./src/browser-adapter-client.js) and exposes a +browser-safe subscription flow. Runtime ordering, trace visibility, and terminal +outcomes come from Traverse public surfaces — not private app logic and not from +other demo app trees. + +## Specs 001 / 002 + +Session chrome uses Spec 001 presentation states +(`idle|loading|loaded|blocked|ended|error`) via `presentationState` on consumer +state (mapped from subscription lifecycle evidence). Capability/progress UI must +follow Spec 002: show invoke/result evidence from the stream — never invent +business fields locally. + +See [`docs/event-ui-conformance-harness.md`](../../docs/event-ui-conformance-harness.md) +for the shared fixture contract used by primary shells. ## Quick Start @@ -16,7 +31,7 @@ node -e "const client = require('./apps/browser-consumer'); console.log(client.A Offline façade load is covered by `bash scripts/ci/youaskm3_starter_kit_smoke.sh`. -Live adapter path (requires `TRAVERSE_REPO`): +Live adapter path (requires `TRAVERSE_REPO`; talks to `browser-adapter serve` directly): ```bash export TRAVERSE_REPO=/path/to/Traverse diff --git a/apps/browser-consumer/index.js b/apps/browser-consumer/index.js index f2e42e8..0a1827f 100644 --- a/apps/browser-consumer/index.js +++ b/apps/browser-consumer/index.js @@ -13,14 +13,14 @@ if (typeof require === "function") { try { - baseClient = require("../react-demo/src/browser-adapter-client.js"); + baseClient = require("./src/browser-adapter-client.js"); } catch { baseClient = null; } } - if (!baseClient && typeof globalThis !== "undefined" && globalThis.TraverseReactDemoClient) { - baseClient = globalThis.TraverseReactDemoClient; + if (!baseClient && typeof globalThis !== "undefined" && globalThis.TraverseBrowserAdapterClient) { + baseClient = globalThis.TraverseBrowserAdapterClient; } if (!baseClient) { @@ -29,6 +29,16 @@ ); } + /** Spec 001 presentation states for session chrome. */ + const PRESENTATION_STATES = Object.freeze([ + "idle", + "loading", + "loaded", + "blocked", + "ended", + "error", + ]); + const APPROVED_BROWSER_CONSUMER_SESSION = { ...baseClient.APPROVED_BROWSER_DEMO_SESSION, title: "Traverse Browser Consumer", @@ -36,8 +46,27 @@ "Traverse's browser-targeted consumer facade for downstream browser-hosted apps like youaskm3.", }; + function presentationStateFromPhase(phase) { + switch (phase) { + case "idle": + return "idle"; + case "streaming": + return "loading"; + case "completed": + return "loaded"; + case "error": + return "error"; + default: + return "loading"; + } + } + function createBrowserConsumerState() { - return baseClient.createLiveDemoState(); + const state = baseClient.createLiveDemoState(); + return { + ...state, + presentationState: presentationStateFromPhase(state.phase), + }; } function buildBrowserConsumerSubscriptionRequest() { @@ -49,7 +78,11 @@ } function applyBrowserConsumerMessage(state, message, created) { - return baseClient.applyBrowserSubscriptionMessage(state, message, created); + const next = baseClient.applyBrowserSubscriptionMessage(state, message, created); + return { + ...next, + presentationState: presentationStateFromPhase(next.phase), + }; } function browserConsumerTraceSummary(trace, terminalResult) { @@ -58,10 +91,12 @@ return { APPROVED_BROWSER_CONSUMER_SESSION, + PRESENTATION_STATES, applyBrowserConsumerMessage, browserConsumerTraceSummary, buildBrowserConsumerSubscriptionRequest, createBrowserConsumerState, + presentationStateFromPhase, runBrowserConsumerSubscription, }; }); diff --git a/apps/browser-consumer/src/browser-adapter-client.js b/apps/browser-consumer/src/browser-adapter-client.js new file mode 100644 index 0000000..a54b606 --- /dev/null +++ b/apps/browser-consumer/src/browser-adapter-client.js @@ -0,0 +1,330 @@ +(function (root, factory) { + const client = factory(); + + if (typeof module === "object" && module.exports) { + module.exports = client; + } + + if (root) { + root.TraverseBrowserAdapterClient = client; + } +})(typeof globalThis !== "undefined" ? globalThis : this, function () { + const APPROVED_BROWSER_DEMO_SESSION = { + title: "Plan Expedition", + summary: + "Traverse evaluates the governed expedition workflow and assembles a final expedition plan.", + request: { + goal: "Plan a two-day alpine expedition for a four-person team.", + requested_target: "local", + caller: "browser_demo", + }, + request_id: "expedition-plan-request-001", + execution_id: "exec_expedition-plan-request-001", + trace_id: "trace_exec_expedition-plan-request-001", + }; + + function createLiveDemoState() { + return { + phase: "idle", + statusLabel: "ready", + streamBanner: "No subscription active yet. Submit the approved request to begin.", + requestId: null, + executionId: null, + stateUpdates: [], + liveTrace: null, + liveResult: null, + error: "", + }; + } + + function buildApprovedSubscriptionRequest() { + return { + subscription_request: { + kind: "browser_runtime_subscription_request", + schema_version: "1.0.0", + governing_spec: "013-browser-runtime-subscription", + request_id: APPROVED_BROWSER_DEMO_SESSION.request_id, + }, + }; + } + + function humanizeName(value) { + return value + .toString() + .split("_") + .filter(Boolean) + .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) + .join(" "); + } + + function formatDetailValue(value) { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return JSON.stringify(value); + } + + function describeStateEvent(stateEvent) { + const details = stateEvent.details || {}; + const parts = []; + + if (details.transition_reason) { + parts.push(humanizeName(details.transition_reason)); + } + + for (const [key, value] of Object.entries(details)) { + if (key === "transition_reason") { + continue; + } + parts.push(`${humanizeName(key)}: ${formatDetailValue(value)}`); + } + + return parts.length > 0 ? parts.join(" · ") : "State update received."; + } + + function formatStateUpdate(stateEvent) { + return { + state: stateEvent.state, + title: humanizeName(stateEvent.state), + timestamp: stateEvent.entered_at, + detail: describeStateEvent(stateEvent), + }; + } + + function normalizeSubscriptionMessage(message) { + const variant = Object.keys(message || {})[0]; + if (!variant) { + return null; + } + return { + variant, + payload: message[variant], + }; + } + + function parseSubscriptionFrame(frame) { + let eventName = ""; + let data = ""; + + for (const line of frame.split(/\r?\n/)) { + if (line.startsWith("event: ")) { + eventName = line.slice("event: ".length); + } else if (line.startsWith("data: ")) { + data += line.slice("data: ".length); + } + } + + if (!eventName || !data) { + return null; + } + + return { + event: eventName, + data: JSON.parse(data), + }; + } + + function parseSubscriptionFrames(text) { + return text + .split(/\r?\n\r?\n/) + .map((frame) => frame.trim()) + .filter(Boolean) + .map(parseSubscriptionFrame) + .filter(Boolean); + } + + async function runLiveBrowserSubscription({ + baseUrl = "", + fetchImpl = globalThis.fetch, + onMessage, + } = {}) { + const adapterPrefix = baseUrl.replace(/\/$/, ""); + const createResponse = await fetchImpl(`${adapterPrefix}/local/browser-subscriptions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(buildApprovedSubscriptionRequest()), + }); + + const createdPayload = await createResponse.text(); + if (!createResponse.ok) { + throw new Error(`local browser adapter setup failed: ${createdPayload}`); + } + + const created = JSON.parse(createdPayload); + const streamResponse = await fetchImpl(`${adapterPrefix}${created.stream_url}`, { + headers: { + Accept: "text/event-stream", + }, + }); + + if (!streamResponse.ok) { + const errorPayload = await streamResponse.text(); + throw new Error(`local browser adapter stream failed: ${errorPayload}`); + } + + if (!streamResponse.body || typeof streamResponse.body.getReader !== "function") { + throw new Error("local browser adapter stream did not expose a readable body"); + } + + const reader = streamResponse.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const messages = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + const frames = buffer.split(/\r?\n\r?\n/); + buffer = frames.pop() || ""; + + for (const frame of frames) { + const parsed = parseSubscriptionFrame(frame.trim()); + if (!parsed) { + continue; + } + + const normalized = normalizeSubscriptionMessage(parsed.data); + if (!normalized) { + continue; + } + + messages.push(normalized); + if (typeof onMessage === "function") { + onMessage(normalized, created); + } + } + } + + const tail = buffer.trim(); + if (tail) { + const parsed = parseSubscriptionFrame(tail); + if (parsed) { + const normalized = normalizeSubscriptionMessage(parsed.data); + if (normalized) { + messages.push(normalized); + if (typeof onMessage === "function") { + onMessage(normalized, created); + } + } + } + } + + return { + created, + messages, + }; + } + + function applyBrowserSubscriptionMessage(state, message, created) { + const nextState = { + ...state, + error: "", + }; + + switch (message.variant) { + case "Lifecycle": { + const lifecycle = message.payload; + nextState.requestId = lifecycle.request_id; + nextState.executionId = lifecycle.execution_id; + nextState.statusLabel = + lifecycle.status === "subscription_established" + ? "streaming" + : lifecycle.status === "stream_completed" + ? "completed" + : lifecycle.status; + nextState.streamBanner = + lifecycle.status === "subscription_established" + ? "Subscription established. Streaming ordered runtime updates." + : "Stream completed. Final trace artifact is now visible."; + nextState.phase = + lifecycle.status === "subscription_established" + ? "streaming" + : lifecycle.status === "stream_completed" + ? "completed" + : nextState.phase; + if (created) { + nextState.subscriptionId = created.subscription_id; + } + return nextState; + } + case "State": { + nextState.phase = "streaming"; + nextState.statusLabel = "streaming"; + nextState.streamBanner = "Subscription established. Streaming ordered runtime updates."; + nextState.stateUpdates = nextState.stateUpdates.concat(formatStateUpdate(message.payload.state_event)); + return nextState; + } + case "TraceArtifact": { + nextState.liveTrace = message.payload.trace; + return nextState; + } + case "StreamTerminal": { + nextState.liveResult = message.payload.result; + nextState.phase = "completed"; + nextState.statusLabel = "completed"; + nextState.streamBanner = "Stream completed. Final trace artifact is now visible."; + return nextState; + } + case "Error": { + nextState.phase = "error"; + nextState.statusLabel = "error"; + nextState.error = message.payload.message; + nextState.streamBanner = message.payload.message; + return nextState; + } + default: + return nextState; + } + } + + function traceSummary(trace, terminalResult) { + if (!trace || !trace.selection || !trace.execution) { + return null; + } + + const output = (terminalResult && terminalResult.output) || (trace.result && trace.result.output) || null; + return { + selection: { + capability: trace.selection.selected_capability_id, + version: trace.selection.selected_capability_version, + placementTarget: trace.execution.placement.selected_target, + placementReason: trace.execution.placement.reason, + }, + emittedEvents: trace.emitted_events || [], + output: output + ? { + planId: output.plan_id, + route: output.route, + weatherSummary: output.weather_summary, + teamStatus: output.team_status, + nextAction: output.next_action, + } + : null, + }; + } + + return { + APPROVED_BROWSER_DEMO_SESSION, + applyBrowserSubscriptionMessage, + buildApprovedSubscriptionRequest, + createLiveDemoState, + humanizeName, + normalizeSubscriptionMessage, + parseSubscriptionFrames, + parseSubscriptionFrame, + runLiveBrowserSubscription, + traceSummary, + }; +}); diff --git a/scripts/ci/browser_consumer_package_smoke.sh b/scripts/ci/browser_consumer_package_smoke.sh index 37a4a2c..3d51bee 100755 --- a/scripts/ci/browser_consumer_package_smoke.sh +++ b/scripts/ci/browser_consumer_package_smoke.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Live smoke for apps/browser-consumer (requires Traverse CLI + react-demo proxy). +# Live smoke for apps/browser-consumer (requires Traverse CLI browser-adapter). # Usage: TRAVERSE_REPO=/path/to/Traverse bash scripts/ci/browser_consumer_package_smoke.sh set -euo pipefail @@ -13,15 +13,9 @@ fi tmpdir="$(mktemp -d)" adapter_log="${tmpdir}/browser-adapter.log" -demo_log="${tmpdir}/react-demo.log" adapter_pid="" -demo_pid="" cleanup() { - if [[ -n "${demo_pid}" ]] && kill -0 "${demo_pid}" 2>/dev/null; then - kill "${demo_pid}" 2>/dev/null || true - wait "${demo_pid}" 2>/dev/null || true - fi if [[ -n "${adapter_pid}" ]] && kill -0 "${adapter_pid}" 2>/dev/null; then kill "${adapter_pid}" 2>/dev/null || true wait "${adapter_pid}" 2>/dev/null || true @@ -48,24 +42,6 @@ for _ in $(seq 1 400); do sleep 0.05 done -( - cd "${repo_root}" - node apps/react-demo/server.mjs --adapter http://127.0.0.1:4174 --port 4173 -) >"${demo_log}" 2>&1 & -demo_pid=$! - -for _ in $(seq 1 200); do - if grep -q "Traverse React demo serving on http://127.0.0.1:4173" "${demo_log}" 2>/dev/null; then - break - fi - if ! kill -0 "${demo_pid}" 2>/dev/null; then - cat "${demo_log}" >&2 - echo "react demo server exited before it reported a listening address" >&2 - exit 1 - fi - sleep 0.05 -done - cd "${repo_root}" node <<'NODE' const assert = require('node:assert/strict'); @@ -76,9 +52,11 @@ const consumer = require('./apps/browser-consumer'); consumer.APPROVED_BROWSER_CONSUMER_SESSION.title, 'Traverse Browser Consumer', ); + assert.ok(consumer.PRESENTATION_STATES.includes('loading')); + assert.equal(consumer.createBrowserConsumerState().presentationState, 'idle'); const createAndStream = await consumer.runBrowserConsumerSubscription({ - baseUrl: 'http://127.0.0.1:4173', + baseUrl: 'http://127.0.0.1:4174', }); assert.ok(createAndStream.created.subscription_id); @@ -106,6 +84,12 @@ const consumer = require('./apps/browser-consumer'); assert.ok(summary); assert.equal(summary.selection.capability, 'expedition.planning.plan-expedition'); assert.equal(summary.output.planId, 'plan-objective-skypilot'); + + let state = consumer.createBrowserConsumerState(); + for (const message of createAndStream.messages) { + state = consumer.applyBrowserConsumerMessage(state, message, createAndStream.created); + } + assert.equal(state.presentationState, 'loaded'); })().catch((error) => { console.error(error); process.exit(1);