From 85ec04974a20a6c3d1e7f505f9b860a65e0a114a Mon Sep 17 00:00:00 2001 From: ammann <161159040+agammann@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:05:43 -0700 Subject: [PATCH] Fix local onboarding checks and speech input recovery --- .github/workflows/ci.yml | 16 +++++++- README.md | 48 ++++++++++++++++++++++-- apps/alexa-simulator/public/app.js | 22 +++++++++-- apps/alexa-simulator/public/index.html | 2 +- package.json | 4 +- pnpm-lock.yaml | 41 ++++++++++---------- pnpm-workspace.yaml | 3 ++ tests/integration/runtime-config.test.ts | 15 +++++--- 8 files changed, 115 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a81a493..be9f9a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,14 @@ on: jobs: verify: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -16,13 +23,17 @@ jobs: with: node-version: 22 cache: pnpm + - uses: actions/setup-python@v7 + with: + python-version: '3.13' - run: pnpm install --frozen-lockfile + - run: pnpm audit --audit-level=low # Type-aware ESLint resolves workspace packages through their generated # declaration files, so a clean runner must build before lint/typecheck. - run: pnpm build - run: pnpm build:customer - run: pnpm build:enrollment - - run: python3 -m unittest discover -s scripts -p 'test_*.py' -v + - run: python -m unittest discover -s scripts -p 'test_*.py' -v - run: node scripts/check-enrollment-state-plan.mjs # Historical evidence integrity only. Current retention template requires # fresh cfn-lint/Guard review; the default checker deliberately rejects it. @@ -31,6 +42,7 @@ jobs: - run: pnpm lint - run: pnpm typecheck - run: pnpm test + - run: pnpm test:e2e docker-demo: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 7649693..5ec81a0 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Flo connects conversational commands to structured repair information and service workflows through MCP. The vehicle-owner preview reviews repair status and customer estimates. The separate shop demo covers diagnostics, compatible parts, supplier availability, approvals, purchasing and scheduling. Both are custom simulations, not a deployed Alexa+ add-on. The adapter boundaries remain extensible beyond automotive repair. +![Flo shop demo showing the selected part, a $561.33 estimate and pending customer approval](docs/demo/release-2026-09-08/estimate.jpg) + > Project status, September 8, 2026: local deterministic engines, four simulated APIs, shop workflow, transaction controls, MCP transport and a read-only vehicle-owner preview are implemented. `/mcp` exposes 28 shop tools in demo mode (25 otherwise); `/customer/mcp` exposes three separate read-only tools only in demo mode and returns 401 otherwise. A separate AWS-hosted customer website supports real Login with Amazon and durable sessions. One independently approved fictional customer completed private approval/redemption and can read its own hosted repair fixture but not another customer's fixture. Sign-in alone never grants repair ownership. These static hosted records are not synchronized with the local shop demo and contain no estimate or schedule. The narrow Bedrock narrator has recorded live verification. Official Alexa+ account linking, add-on deployment, certification, MCP App packaging, AgentCore and durable shop business state remain incomplete. **Certification is a separate gate from the hackathon.** Alexa+ policy excludes exclusively internal/B2B add-ons. The vehicle-owner preview is the first consumer-facing increment, not a certification-ready release. See the [complete documentation review and implementation tracker](docs/alexa-plus-certification-plan.md). @@ -169,7 +171,7 @@ flo/ ## Requirements -- Node.js 22 or newer +- Node.js 22.13 or newer (required by pnpm 11) - pnpm 11 No AWS account, commercial supplier key, or private repair-shop integration is needed for the local demo. @@ -177,8 +179,10 @@ No AWS account, commercial supplier key, or private repair-shop integration is n ## Local setup ```bash +git clone https://github.com/agammann/flo.git +cd flo cp .env.example .env -pnpm install +pnpm install --frozen-lockfile pnpm build pnpm test pnpm dev @@ -200,6 +204,12 @@ Service endpoints: In local demo mode, MCP requests can set `x-flo-role` to `technician`, `service_advisor`, `manager`, or `administrator`. Production mode must authenticate identities and must not trust a caller-selected role header. +The shop accepts typed commands even when speech recognition is unavailable or +blocked. If microphone or speech-service access is refused, the status beside +the command field explains how to continue by typing. Successful speech input +depends on the browser and its recognition service; the local workflow does not +require microphone permission. + ## Commands ```bash @@ -207,6 +217,7 @@ pnpm build # compile every workspace package pnpm test # run compiled unit and integration tests pnpm test:unit pnpm test:integration +pnpm test:e2e # complete HTTP/MCP workflow and transport checks pnpm typecheck pnpm lint pnpm dev:services # start mock APIs and MCP @@ -233,12 +244,43 @@ The suite covers: Run the same quality gates used by CI: ```bash +pnpm install --frozen-lockfile +pnpm audit --audit-level=low +pnpm build +pnpm build:customer +pnpm build:enrollment pnpm lint pnpm typecheck -pnpm build pnpm test +pnpm test:e2e +node --test scripts/*.test.mjs +python -m unittest discover -s scripts -p 'test_*.py' -v +``` + +Build before lint and type checking in a fresh checkout: workspace imports resolve +through the generated declaration files. `pnpm test:e2e` starts disposable HTTP +services on random loopback ports and exercises the diagnosis-to-schedule flow +and real MCP negotiation. It needs no running demo, Docker or AWS account. The +Python checks use Python 3.13 in CI; use `python3` if that is your installed command. + +For the container startup and database transaction checks, run: + +```bash +docker compose up --build -d +node scripts/docker-smoke.mjs +docker compose -p flo-customer-test -f docker-compose.customer-test.yml up --abort-on-container-exit --exit-code-from customer-contract +docker compose -p flo-customer-test -f docker-compose.customer-test.yml down --volumes --remove-orphans +docker compose down --volumes --remove-orphans ``` +The HTTP smoke resets the local demo before checking approval, confirmed ordering +and scheduling, resumed context and customer-only estimates. Run it only against +a disposable local demo. The database suite uses DynamoDB Local in an isolated +network with synthetic identities; it does not verify live AWS IAM or Amazon +sign-in. Platform-specific filesystem tests run on Linux; Windows refusal tests +run on Windows. CI checks both platforms. Hosted AWS observations above remain +dated September evidence. + ## Adding an integration Implement the relevant interface in `packages/adapters`, validate the provider response into Flo domain types, and inject the adapter into the orchestrator. The supplier contract includes search, availability, order, status, cancel, and reset operations. `MockSupplierAdapter` is intentionally an HTTP implementation rather than a frontend fixture, so a future Napa, AutoZone, dealer, Amazon Business, or generic ERP adapter can replace it without changing the MCP surface. See `docs/api/adding-an-adapter.md`. diff --git a/apps/alexa-simulator/public/app.js b/apps/alexa-simulator/public/app.js index 9ba735b..028415c 100644 --- a/apps/alexa-simulator/public/app.js +++ b/apps/alexa-simulator/public/app.js @@ -143,10 +143,26 @@ $("#reset").addEventListener("click", async () => { const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (Recognition) { const recognition = new Recognition(); recognition.lang = "en-US"; recognition.interimResults = false; - recognition.addEventListener("start", () => talk.classList.add("listening")); - recognition.addEventListener("end", () => talk.classList.remove("listening")); + let recognizing = false; + const voiceNote = $("#voiceNote"); + recognition.addEventListener("start", () => { talk.classList.add("listening"); voiceNote.textContent = "Listening… Speak a shop command."; }); + recognition.addEventListener("end", () => { + recognizing = false; talk.classList.remove("listening"); + if (voiceNote.textContent.startsWith("Listening")) voiceNote.textContent = "Voice input uses your browser’s speech recognition when available."; + }); + recognition.addEventListener("error", (event) => { + voiceNote.textContent = ["not-allowed", "service-not-allowed"].includes(event.error) + ? "Voice input is blocked by the browser or speech service. Type a command to continue." + : event.error === "no-speech" ? "No speech was detected. Try again or type a command." + : "Voice input could not finish. Try again or type a command."; + }); recognition.addEventListener("result", (event) => { const command = event.results[0][0].transcript; input.value = command; void runCommand(command); }); - talk.addEventListener("click", () => recognition.start()); + talk.addEventListener("click", () => { + if (recognizing) return; + recognizing = true; + try { recognition.start(); } + catch { recognizing = false; voiceNote.textContent = "Voice input could not start. Try again or type a command."; } + }); } else { talk.hidden = true; $("#voiceNote").textContent = "Type a command to run the voice-first demo workflow."; } fetch("/api/health").then(async (response) => { diff --git a/apps/alexa-simulator/public/index.html b/apps/alexa-simulator/public/index.html index 4ba11c5..a4aff54 100644 --- a/apps/alexa-simulator/public/index.html +++ b/apps/alexa-simulator/public/index.html @@ -76,7 +76,7 @@

Keep your hands on the work.

-

Voice input uses your browser’s speech recognition when available.

+

Voice input uses your browser’s speech recognition when available.

diff --git a/package.json b/package.json index abec924..a7ed41d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "packageManager": "pnpm@11.19.0", "engines": { - "node": ">=22.0.0" + "node": ">=22.13.0" }, "scripts": { "build": "node scripts/compile-workspaces.mjs", @@ -16,7 +16,7 @@ "test": "node scripts/compile-workspaces.mjs && node scripts/run-tests.mjs", "test:unit": "node scripts/compile-workspaces.mjs && node scripts/run-tests.mjs packages", "test:integration": "node scripts/compile-workspaces.mjs && node scripts/run-tests.mjs tests", - "test:e2e": "node scripts/compile-workspaces.mjs && node scripts/run-tests.mjs tests/e2e", + "test:e2e": "node scripts/compile-workspaces.mjs && node --test tests/dist/integration/demo-workflow.test.js tests/dist/integration/mcp-transport.test.js", "typecheck": "node scripts/compile-workspaces.mjs --noEmit", "lint": "eslint . --max-warnings=0", "demo:reset": "node scripts/reset-demo.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8bf8fc..faa12fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + hono@>=4.13.0 <4.13.7: 4.13.7 + importers: .: @@ -181,7 +184,7 @@ importers: version: 2.0.0 '@modelcontextprotocol/node': specifier: ^2.0.0 - version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.5) + version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.7) '@modelcontextprotocol/server': specifier: ^2.0.0 version: 2.0.0 @@ -642,7 +645,7 @@ packages: resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: 4.13.7 '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} @@ -677,7 +680,7 @@ packages: engines: {node: '>=20'} peerDependencies: '@modelcontextprotocol/server': ^2.0.0 - hono: ^4.11.4 + hono: 4.13.7 peerDependenciesMeta: hono: optional: true @@ -863,11 +866,11 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@1.1.18: - resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} bytes@3.1.2: @@ -1143,8 +1146,8 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hono@4.13.5: - resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} engines: {node: '>=16.9.0'} http-errors@2.0.1: @@ -1866,9 +1869,9 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@hono/node-server@1.19.17(hono@4.13.5)': + '@hono/node-server@1.19.17(hono@4.13.7)': dependencies: - hono: 4.13.5 + hono: 4.13.7 '@humanfs/core@0.19.2': dependencies: @@ -1900,12 +1903,12 @@ snapshots: dependencies: zod: 4.5.4 - '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.5)': + '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.7)': dependencies: - '@hono/node-server': 1.19.17(hono@4.13.5) + '@hono/node-server': 1.19.17(hono@4.13.7) '@modelcontextprotocol/server': 2.0.0 optionalDependencies: - hono: 4.13.5 + hono: 4.13.7 '@modelcontextprotocol/server@2.0.0': dependencies: @@ -2146,12 +2149,12 @@ snapshots: bowser@2.14.1: {} - brace-expansion@1.1.18: + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.9: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -2469,7 +2472,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.13.5: {} + hono@4.13.7: {} http-errors@2.0.1: dependencies: @@ -2551,11 +2554,11 @@ snapshots: minimatch@10.2.6: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.18 + brace-expansion: 1.1.21 mnemonist@0.38.3: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 001c9d0..2d349d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,3 +6,6 @@ packages: allowBuilds: esbuild: false + +overrides: + 'hono@>=4.13.0 <4.13.7': 4.13.7 diff --git a/tests/integration/runtime-config.test.ts b/tests/integration/runtime-config.test.ts index ec60ea1..f2d4919 100644 --- a/tests/integration/runtime-config.test.ts +++ b/tests/integration/runtime-config.test.ts @@ -4,6 +4,9 @@ import { resolve } from "node:path"; import { describe, it } from "node:test"; import { isDemoModeEnabled } from "@flo/mcp"; +const readNarratorTemplate = async (): Promise => + (await readFile(resolve("infra/aws/bedrock-narrator/template.yaml"), "utf8")).replace(/\r\n/g, "\n"); + describe("Flo runtime configuration", () => { it("requires an explicit opt-in to expose demo tools in a production runtime", () => { assert.equal(isDemoModeEnabled({ NODE_ENV: "production" }), false); @@ -32,7 +35,7 @@ describe("Flo runtime configuration", () => { assert.doesNotMatch(example, /^ALLOWED_ORIGINS=/m); }); it("keeps finite narrator log retention in source and documents the existing-group import gate", async () => { - const template = await readFile(resolve("infra/aws/bedrock-narrator/template.yaml"), "utf8"); + const template = await readNarratorTemplate(); const block = template.split(" NarratorLogGroup:")[1]?.split(" NarratorFunction:")[0]; assert.ok(block); assert.match(block, /Type: AWS::Logs::LogGroup/); @@ -45,7 +48,7 @@ describe("Flo runtime configuration", () => { }); it("encrypts finite operational logs without recording customer data or credentials", async () => { - const template = await readFile(resolve("infra/aws/bedrock-narrator/template.yaml"), "utf8"); + const template = await readNarratorTemplate(); for (const name of ["NarratorLogGroup", "NarratorAccessLogGroup"]) { const block = template.split(` ${name}:\n`)[1]!.split(/\n {2}[A-Za-z]+:\n/)[0]!; assert.match(block, /RetentionInDays: 7/); @@ -60,7 +63,7 @@ describe("Flo runtime configuration", () => { }); it("scopes narrator log-key caller access and separates metadata from crypto context", async () => { - const template = await readFile(resolve("infra/aws/bedrock-narrator/template.yaml"), "utf8"); + const template = await readNarratorTemplate(); const key = template.split(" NarratorLogsKey:\n")[1]!.split(" NarrationAllowanceKey:")[0]!.replace(/^\s*#.*$/gm, ""); const crypto = key.split("- Sid: NarratorLogCryptoViaCloudWatch\n")[1]?.split("- Sid:")[0]; const metadata = key.split("- Sid: NarratorLogKeyMetadataViaCloudWatch\n")[1]?.split("- Sid:")[0]; @@ -89,7 +92,7 @@ describe("Flo runtime configuration", () => { }); it("keeps a positive bounded concurrency reservation and preserves narrow runtime actions", async () => { - const template = await readFile(resolve("infra/aws/bedrock-narrator/template.yaml"), "utf8"); + const template = await readNarratorTemplate(); assert.match(template, /^ {6}ReservedConcurrentExecutions: 2(?: #.*)?$/m); assert.doesNotMatch(template, /NarratorReservedConcurrency/); const role = template.split(" NarratorRole:\n")[1]!.split(" NarratorRuntimePolicy:")[0]!; @@ -104,7 +107,7 @@ describe("Flo runtime configuration", () => { }); it("backs up the allowance without granting runtime restore or general KMS access", async () => { - const template = await readFile(resolve("infra/aws/bedrock-narrator/template.yaml"), "utf8"); + const template = await readNarratorTemplate(); const table = template.split(" NarrationAllowance:\n")[1]!.split(" NarratorRole:")[0]!; assert.match(table, /KMSMasterKeyId: !GetAtt NarrationAllowanceKey.Arn/); assert.match(table, /SSEType: KMS/); @@ -128,7 +131,7 @@ describe("Flo runtime configuration", () => { assert.equal(exception.resource, "NarratorFunction"); assert.deepEqual(exception.exceptions.map(value => value.rule).sort(), ["LAMBDA_DLQ_CHECK", "LAMBDA_INSIDE_VPC"]); assert.ok(Date.parse(exception.reviewBy) > Date.now(), "Policy exceptions need owner re-review after their review date"); - const template = await readFile(resolve("infra/aws/bedrock-narrator/template.yaml"), "utf8"); + const template = await readNarratorTemplate(); assert.match(template, /AuthorizationType: AWS_IAM/); assert.match(template, /RouteKey: POST \/narrate/); assert.doesNotMatch(template, /AWS::Lambda::(Url|EventSourceMapping|EventInvokeConfig)|AWS::Events::Rule|AWS::SNS::Subscription/);