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
16 changes: 14 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -31,6 +42,7 @@ jobs:
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
- run: pnpm test:e2e

docker-demo:
runs-on: ubuntu-latest
Expand Down
48 changes: 45 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -169,16 +171,18 @@ 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.

## 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
Expand All @@ -200,13 +204,20 @@ 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
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
Expand All @@ -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`.
Expand Down
22 changes: 19 additions & 3 deletions apps/alexa-simulator/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/alexa-simulator/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ <h1 id="page-title">Keep your hands on the work.</h1>
<button class="talk" type="button" id="talk" title="Use microphone" aria-label="Use microphone"><span aria-hidden="true">●</span></button>
<button class="send" type="submit">Run <span aria-hidden="true">↗</span></button>
</form>
<p class="voice-note" id="voiceNote">Voice input uses your browser’s speech recognition when available.</p>
<p class="voice-note" id="voiceNote" role="status">Voice input uses your browser’s speech recognition when available.</p>
</section>

<section class="operational panel" aria-labelledby="workspace-title">
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
41 changes: 22 additions & 19 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ packages:

allowBuilds:
esbuild: false

overrides:
'hono@>=4.13.0 <4.13.7': 4.13.7
15 changes: 9 additions & 6 deletions tests/integration/runtime-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { resolve } from "node:path";
import { describe, it } from "node:test";
import { isDemoModeEnabled } from "@flo/mcp";

const readNarratorTemplate = async (): Promise<string> =>
(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);
Expand Down Expand Up @@ -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/);
Expand All @@ -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/);
Expand All @@ -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];
Expand Down Expand Up @@ -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]!;
Expand All @@ -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/);
Expand All @@ -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/);
Expand Down
Loading