diff --git a/.github/workflows/lamatic-update.yml b/.github/workflows/lamatic-update.yml new file mode 100644 index 000000000..9b1debd43 --- /dev/null +++ b/.github/workflows/lamatic-update.yml @@ -0,0 +1,26 @@ +name: Detect & Sync Lamatic Flows + +on: + push: + branches: + - feat/cloud-carbon-advisor + paths: + - '**/lamatic/flows/**' + +jobs: + detect-flow-changes: + environment: feat/cloud-carbon-advisor + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[LAMATIC-COMMIT]')" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Sync Flows to lamatic + uses: Lamatic/sync-flows-to-lamatic@v1 + with: + lamatic-endpoint: ${{ secrets.LAMATIC_PROJECT_ENDPOINT }} + api-key: ${{ secrets.LAMATIC_PROJECT_API_KEY }} + project-id: ${{ secrets.LAMATIC_PROJECT_ID }} + auto-deploy: ${{ secrets.LAMATIC_AUTO_DEPLOY_PROJECT }} + diff --git a/kits/cloud-carbon-advisor/.env.example b/kits/cloud-carbon-advisor/.env.example new file mode 100644 index 000000000..03ea82a5d --- /dev/null +++ b/kits/cloud-carbon-advisor/.env.example @@ -0,0 +1,9 @@ +# Lamatic credentials for deploying and running the carbon-advisor flow. +# Copy to .env.local (never commit real values) and fill in from Studio > Settings. +LAMATIC_API_KEY=your_api_key_here +LAMATIC_PROJECT_ID=your_project_id_here +LAMATIC_API_URL=https://your-project.lamatic.dev/graphql + +# The deployed flow's ID — Studio > flow details panel (three-dot menu) > Flow ID. +# Left blank on purpose: unset ⇒ the app runs in offline heuristic mode. +LAMATIC_CARBON_ADVISOR_FLOW_ID= diff --git a/kits/cloud-carbon-advisor/.gitignore b/kits/cloud-carbon-advisor/.gitignore new file mode 100644 index 000000000..a69fa86db --- /dev/null +++ b/kits/cloud-carbon-advisor/.gitignore @@ -0,0 +1,10 @@ +# Never commit real credentials — every .env* is ignored except the template. +.env +.env.* +!.env.example + +# dependencies pulled in by the app +apps/node_modules +apps/.next + +.DS_Store diff --git a/kits/cloud-carbon-advisor/README.md b/kits/cloud-carbon-advisor/README.md new file mode 100644 index 000000000..f6edefd03 --- /dev/null +++ b/kits/cloud-carbon-advisor/README.md @@ -0,0 +1,190 @@ +# Cloud Carbon Advisor + +Git blame for your cloud carbon. + +Upload a cloud usage export (a [FOCUS](https://focus.finops.org/)-style billing +CSV). It computes an auditable CO₂e footprint per service and region, finds your +carbon hotspots, and returns an **impact-ranked decarbonization plan** — with a +specific lever for each hotspot, its effort, its risk, and a projected saving. + +Every gram is computed by code from published emissions factors. The model never +emits a number — it only decides *which lever to pull*. See [Architecture](#architecture). + +## What you get back + +- **A footprint, per service and region** — estimated electricity (kWh), the + region's grid carbon intensity, and the resulting kgCO₂e, aggregated into the + hotspots that actually matter. +- **A diagnosed driver for each hotspot** — is this carbon coming from a *dirty + grid*, *heavy compute*, *storage bloat*, or *egress*? The driver decides the + fix. +- **A costed lever** — a concrete action (migrate region, move to ARM, tier cold + storage, schedule to low-carbon hours…), its effort and risk, and a projected + reduction in kgCO₂e — with cross-region moves flagged, never presented as free. +- **The region-migration ceiling** — computed exactly: how much CO₂e each + hotspot would drop if it ran on the cleanest same-provider region. + +## Why this and not the cloud providers' own carbon tools + +| Tool | Multi-cloud | No cloud credentials needed | Tells you **what to do** | +|---|:---:|:---:|:---:| +| AWS Customer Carbon Footprint Tool | ❌ AWS only | ❌ needs the account | ❌ reports only | +| GCP Carbon Footprint / Azure Emissions Impact | ❌ one cloud each | ❌ needs the account | ❌ reports only | +| Cloud Carbon Footprint (OSS) | ✅ | ❌ connects to billing APIs | ⚠️ estimates, limited guidance | +| **This kit** | ✅ | ✅ works from a static usage export | ✅ a ranked, costed lever per hotspot | + +The providers' first-party tools each cover one cloud, require access to the +account, and stop at *reporting* — they tell you your number, not which of your +workloads to move or how. [Cloud Carbon Footprint](https://www.cloudcarbonfootprint.org/), +the excellent open-source estimator this kit borrows its methodology from, is +multi-cloud but is a self-hosted application that connects to your billing APIs. + +This kit deliberately trades live integration for zero-credential portability: it +works from a usage **file** you already have, across any provider in one report, +and its differentiated output is the **plan** — a prioritized list of levers an +engineer can act on this week — not another dashboard of numbers. + +**What this kit does not claim:** it is not an audited carbon-accounting system +of record (see [Limitations](#limitations)), and it does not connect to any cloud +API. It is a fast, defensible planning tool for *where to cut carbon first*. + +## Architecture + +```text +FOCUS usage CSV + │ + ├─ apps/lib/compute-emissions.ts deterministic — runs in the Next.js app + │ energy(kWh) × PUE × grid-intensity(region) = gCO₂e, per service/region; + │ ranks hotspots; computes the cleaner-region delta exactly — all arithmetic + │ + └─ flows/carbon-advisor judgment only — runs in Lamatic + ├─ Diagnose (InstructorLLM) dirty-grid | compute-heavy | storage-bloat + │ | egress-heavy — driver class, no numbers + ├─ Recommend (InstructorLLM) a lever + effort/risk + a reductionKey + │ bucket (not a number) + └─ Finalize (code) coerce every enum, drop any invented + hotspot id — never trust the model's shape + │ + └─ apps/lib/assemble.ts deterministic — prices each chosen lever + from a fixed reductionKey→multiplier table, reconciles totals, writes + relatable equivalences, and produces the report. +``` + +**The model never does arithmetic.** Estimating emissions is a chain of lookups +(usage × energy coefficient × PUE × grid intensity); pricing a lever is a bucket +multiplier times the hotspot's own footprint. Both run in TypeScript, are +unit-tested, and are the same code the offline eval asserts against. *Which +driver, and which lever* is a judgment call — the one part of the pipeline that +genuinely needs a model. + +Enforcement of "never a number" is layered, not just a prompt request: the +[constitution](./constitutions/default.md) states it, the flow's `Finalize` code +node forces every field into a fixed enum and never reads a numeric field out of +the model's output, and the app prices levers from its own tested table — so +every figure in a report traces back to a deterministic source. `npm run eval` +asserts exactly that. + +## Emissions methodology & sources + +This kit follows the methodology of the open-source +[Cloud Carbon Footprint](https://www.cloudcarbonfootprint.org/docs/methodology) +project: + +```text +emissions (gCO₂e) = energy (kWh) × PUE × grid carbon intensity (gCO₂e/kWh) +energy (kWh) = usage amount × energy coefficient for that usage class +``` + +- **Energy coefficients** (compute per vCPU-hour, memory/storage per GB-hour, + network per GB) — Cloud Carbon Footprint methodology, including its ~40% lower + coefficient for ARM/Graviton silicon. +- **PUE** — provider sustainability disclosures (AWS 1.135, Google 1.10, Azure + 1.125), 1.20 where unknown. +- **Grid carbon intensity by region** — Cloud Carbon Footprint's + grid-emissions-factors, provider carbon-data pages, and Ember's yearly grid + averages. + +All factors live in one auditable file, [`apps/lib/emissions-factors.ts`](./apps/lib/emissions-factors.ts), +and are trivially swappable. These are order-of-magnitude-correct *planning* +figures; the kit's value is the **relative** comparison between regions and +levers, which is robust to the absolute uncertainty in any single coefficient. + +## Quickstart + +**Option A — full experience (with the Lamatic flow):** + +1. Import [`flows/carbon-advisor.ts`](./flows/carbon-advisor.ts) into Lamatic + Studio, attach a Gemini (or other) credential to the two model nodes, deploy, + and copy the Flow ID. +2. `cd kits/cloud-carbon-advisor/apps` +3. `cp .env.example .env.local` and fill in the four values (see below). +4. `npm install && npm run dev` +5. Open http://localhost:3000, press **Load example**, then **Analyze footprint**. + +**Option B — offline (no credentials):** skip step 1 and leave +`LAMATIC_CARBON_ADVISOR_FLOW_ID` blank. The app computes the full footprint and +runs a deterministic **heuristic** plan, clearly badged, so you can explore it +with zero setup. Connect the flow to replace the heuristic with real reasoning. + +Independent of Studio: `npm run eval` runs 58 offline assertions (numeric +integrity, classifier, cleaner-region math, savings pricing, model-output +coercion, CSV-injection) with no network and no model calls. + +## Environment + +| Variable | Source | +|---|---| +| `LAMATIC_API_KEY` | Studio → Settings → API Keys | +| `LAMATIC_PROJECT_ID` | Studio → Settings → Project → Project ID | +| `LAMATIC_API_URL` | Studio → API Docs → Endpoint | +| `LAMATIC_CARBON_ADVISOR_FLOW_ID` | Flow → three-dot menu → Flow ID | + +All four are read server-side only and never prefixed `NEXT_PUBLIC_`: the three +`LAMATIC_API_*` values are consumed by the Lamatic client used in +`apps/actions/orchestrate.ts`, and `LAMATIC_CARBON_ADVISOR_FLOW_ID` is resolved +through the `apps/orchestrate.js` deployment manifest. Account identifiers are +stripped before the flow is called. + +## Input format + +A CSV with at least these columns (common aliases are accepted, e.g. +`Region`/`RegionId`, `UsageQuantity`/`PricingQuantity`): + +`ServiceName`, `ServiceCategory`, `RegionId`, `PricingUnit`, `PricingQuantity` — +plus optional `ProviderName`, `SubAccountId`, `SkuId`, `BilledCost`, +`BillingCurrency`. See [`apps/public/sample-usage.csv`](./apps/public/sample-usage.csv) +for a working example. + +## Limitations + +- **Planning-grade, not audited.** Estimates use public average factors, not your + actual metered power draw or your provider's market-based (contractual) carbon + accounting. Treat the output as *where to look first*, not a compliance figure. +- **Representative region set.** [`emissions-factors.ts`](./apps/lib/emissions-factors.ts) + covers the most-used regions and the cleanest ones; an unlisted region falls + back to the global grid average (~475 gCO₂e/kWh) rather than scoring zero. +- **No utilisation signal.** Billing usage doesn't reveal CPU utilisation, so + `over-provisioned` is diagnosed conservatively and rightsizing is an estimate. +- **Location-based intensity.** Grid intensities are yearly location-based + averages, not hour-by-hour marginal intensity; `schedule-shift` savings are + therefore directional. +- **The region-migration ceiling is a ceiling.** Moving a workload to the + cleanest grid ignores data-residency, latency, and egress-repatriation cost — + which is exactly why the model weighs those into `effort`/`risk` and flags + cross-region moves. + +## Common failure modes + +| Symptom | Cause | Fix | +|---|---|---| +| `Missing LAMATIC_…` | No `.env.local`, or a blank value | Fill in the four variables (or leave the Flow ID blank for offline mode) and restart | +| `CSV is missing required column(s)` | Not a FOCUS-style export, or wrong delimiter | Ensure ServiceName, RegionId, PricingUnit, PricingQuantity columns exist | +| Badge says **offline heuristic** | No Flow ID configured | Expected — set `LAMATIC_CARBON_ADVISOR_FLOW_ID` to use the Lamatic flow | +| An unlisted region shows ~475 g/kWh | Region not in the factor table | Expected fallback; add it to `emissions-factors.ts` for precision | +| A hotspot shows a `flags` chip | The model returned an out-of-range enum or a bad id | Expected and handled — the coerced-to-safe value is shown, not an error | +| `Could not reach Lamatic` | Wrong `LAMATIC_API_URL`, or no network | Re-copy the endpoint from Studio → API Docs | + +## License + +Contributed to [Lamatic AgentKit](https://github.com/Lamatic/AgentKit) under the +repository's license. diff --git a/kits/cloud-carbon-advisor/agent.md b/kits/cloud-carbon-advisor/agent.md new file mode 100644 index 000000000..07e1af4b2 --- /dev/null +++ b/kits/cloud-carbon-advisor/agent.md @@ -0,0 +1,152 @@ +# Cloud Carbon Advisor + +## Overview + +An agent that answers the question a cloud carbon dashboard never does: *what do +I do about it?* It takes a FOCUS usage export, computes an auditable CO₂e +footprint per service and region, finds the hotspots, diagnoses why each one +emits what it does, and returns a specific, costed decarbonization lever for each +— or says honestly that no single driver dominates. + +## Purpose + +Every first-party cloud carbon tool (AWS Customer Carbon Footprint Tool, Google +Cloud Carbon Footprint, Azure Emissions Impact Dashboard) covers a single cloud, +requires access to the account, and stops at *reporting* — a number, a trend, a +breakdown. None of them rank *which of your workloads to move first*, or tell you +the lever and its cost. Cloud Carbon Footprint (open source) is multi-cloud and +estimate-based but is a self-hosted app wired to your billing APIs. + +This agent automates the step after the number: from a usage file — any provider, +no cloud credentials — it produces a prioritized, costed plan, with the +discipline a carbon figure demands: every number is computed by code, never by a +model. + +## Architecture + +```text +FOCUS usage CSV + │ + ├─ apps/lib/compute-emissions.ts deterministic — runs in the Next.js app + │ └─ Hotspot[] energy × PUE × grid-intensity = gCO₂e, + │ ranked; cleaner-region delta computed exactly + │ + └─ flows/carbon-advisor judgment — runs in Lamatic + ├─ Diagnose (InstructorLLM) driverClass + confidence + evidence, no numbers + ├─ Recommend (InstructorLLM) action + effort/risk + reductionKey bucket + └─ Finalize (code) coerce every enum, drop invented ids + │ + └─ apps/lib/assemble.ts deterministic — prices each lever from a + fixed multiplier table, builds the report +``` + +Estimating emissions and pricing a lever are both solved arithmetic problems, so +they run in TypeScript and are unit-tested (`npm run eval`). *Which driver, and +which lever* is judgment over the hotspot's shape — which is what the model is +for. The flow never receives account identifiers (stripped before the call) and +never emits a number. + +## Flows + +### `carbon-advisor` + +**Trigger** — API Request (GraphQL). Accepts: + +| Field | Type | Meaning | +|---|---|---| +| `hotspots` | `[string]` | Ranked `Hotspot[]` from the app's footprint engine, each JSON-encoded, identifier-free | +| `periodLabel` | string | Human-readable billing period | +| `currency` | string | ISO currency code | + +**Processing** — Diagnose classifies each hotspot's dominant carbon driver +(`dirty-grid`, `compute-heavy`, `storage-bloat`, `egress-heavy`, +`over-provisioned`, `mixed`) with evidence and rejected alternatives. Recommend +chooses one lever and a `reductionKey` bucket, weighing cross-region data +residency and latency into effort/risk. Finalize coerces every enum into range, +drops any hotspot id the model invented, and never reads a number out of the +model's output. + +**Response** — + +```typescript +{ + diagnoses: Array<{ hotspotId, driverClass, confidence, evidence, reasoning, rejectedDrivers }>, + recommendations: Array<{ hotspotId, action, rationale, effort, risk, prerequisites, reductionKey }> +} +``` + +The app then prices each `reductionKey` deterministically, reconciles totals, and +renders the footprint dashboard and plan. + +**When to use it** — at the end of a billing period, before a FinOps/GreenOps +review, or whenever "our cloud footprint is X tonnes" needs to become "and here +are the three moves that cut it most". + +**Dependencies** — one structured-output ("instructor") model for diagnosis and +one for recommendation. + +## Guardrails + +Beyond [`constitutions/default.md`](./constitutions/default.md): + +- **Never emit a number.** Neither model node is asked for a gram, kWh, or + percentage. The app computes every figure from the footprint and a fixed + multiplier table. +- **Never invent an enum or an id.** `driverClass`, `reductionKey`, `effort`, and + `risk` are coerced to a safe value if out of range; a `hotspotId` that does not + match an input hotspot is dropped. Coerced fields surface as a `flags` chip + rather than a silent error. +- **No greenwashing.** A cross-continent migration is never presented as free — + its residency/latency cost is raised into `risk` and a prerequisite, and an + honest `region-migration-partial` beats a reckless `-major`. + +### Not in scope + +- Live cloud billing APIs or credentials — this kit is static-file-in by design. +- Audited, market-based carbon accounting — figures are location-based planning + estimates (see the README's Limitations). +- Hour-by-hour marginal grid intensity — intensities are yearly averages, so + `schedule-shift` savings are directional. + +## Integration reference + +| Service | Purpose | Credential | +|---|---|---| +| Lamatic | Hosts and executes the flow | `LAMATIC_API_KEY`, `LAMATIC_PROJECT_ID`, `LAMATIC_API_URL` | +| Structured-output model (diagnose) | Carbon-driver classification per hotspot | Configured in Studio on the Diagnose node | +| Structured-output model (recommend) | Lever + bucket per hotspot | Configured in Studio on the Recommend node | + +No cloud-provider credentials are ever requested — usage data is a file the user +supplies. + +## Environment setup + +| Variable | Source | +|---|---| +| `LAMATIC_API_KEY` | Studio → Settings → API Keys | +| `LAMATIC_PROJECT_ID` | Studio → Settings → Project → Project ID | +| `LAMATIC_API_URL` | Studio → API Docs → Endpoint | +| `LAMATIC_CARBON_ADVISOR_FLOW_ID` | Flow → three-dot menu → Flow ID (blank ⇒ offline heuristic mode) | + +## Quickstart + +1. Import `flows/carbon-advisor.ts` into Lamatic Studio, attach a model + credential to both model nodes, deploy, and copy the Flow ID. +2. `cd kits/cloud-carbon-advisor/apps` +3. `cp .env.example .env.local` and fill in the four values above. +4. `npm install && npm run dev` +5. Open http://localhost:3000, press **Load example**, then **Analyze footprint**. + +Offline: leave the Flow ID blank to run the deterministic heuristic plan with no +credentials. `npm run eval` runs the 58-assertion offline suite. + +## Common failure modes + +| Symptom | Cause | Fix | +|---|---|---| +| `Missing LAMATIC_…` | No `.env.local`, or a blank required value | Fill the variables (Flow ID may stay blank) and restart | +| `CSV is missing required column(s)` | Not a FOCUS-style export | Ensure ServiceName, RegionId, PricingUnit, PricingQuantity exist | +| Badge shows **offline heuristic** | No Flow ID configured | Set `LAMATIC_CARBON_ADVISOR_FLOW_ID` to use the flow | +| A `flags` chip on a hotspot | Model returned an out-of-range enum or bad id | Expected and handled — the safe coerced value is shown | +| `Could not reach Lamatic` | Wrong `LAMATIC_API_URL` or no network | Re-copy the endpoint from Studio → API Docs | +| Every recommendation is "no recommendation" | The model node is misconfigured | Confirm a credential is attached to both model nodes in Studio | diff --git a/kits/cloud-carbon-advisor/apps/.env.example b/kits/cloud-carbon-advisor/apps/.env.example new file mode 100644 index 000000000..d979c993a --- /dev/null +++ b/kits/cloud-carbon-advisor/apps/.env.example @@ -0,0 +1,9 @@ +# Lamatic credentials — Studio > Settings +LAMATIC_API_KEY=your_api_key_here +LAMATIC_PROJECT_ID=your_project_id_here +LAMATIC_API_URL=https://your-project.lamatic.dev/graphql + +# Flow ID — Studio > flow details panel (three-dot menu) > Flow ID. +# Left blank on purpose: an unedited copy runs the app in offline heuristic +# mode. Set a real Flow ID only to enable the Lamatic flow. +LAMATIC_CARBON_ADVISOR_FLOW_ID= diff --git a/kits/cloud-carbon-advisor/apps/.gitignore b/kits/cloud-carbon-advisor/apps/.gitignore new file mode 100644 index 000000000..e7c917b79 --- /dev/null +++ b/kits/cloud-carbon-advisor/apps/.gitignore @@ -0,0 +1,19 @@ +# dependencies +/node_modules + +# next.js +/.next/ +/out/ +next-env.d.ts + +# production +/build + +# env — never commit real credentials; every .env* is ignored except the template +.env +.env.* +!.env.example + +# misc +.DS_Store +*.tsbuildinfo diff --git a/kits/cloud-carbon-advisor/apps/actions/orchestrate.ts b/kits/cloud-carbon-advisor/apps/actions/orchestrate.ts new file mode 100644 index 000000000..1e7822ca0 --- /dev/null +++ b/kits/cloud-carbon-advisor/apps/actions/orchestrate.ts @@ -0,0 +1,137 @@ +"use server"; + +import { headers } from "next/headers"; +import { parseFocusCsv, FocusParseError } from "../lib/parse-focus"; +import { validateUploadSize, validateRowCount, UploadValidationError } from "../lib/validate-upload"; +import { + computeFootprintLines, + buildHotspots, + averageGridIntensity, + providerMix, +} from "../lib/compute-emissions"; +import { getLamaticClient, flowIdFor, isFlowConfigured } from "../lib/lamatic-client"; +import { coercePlan, localHeuristicPlan } from "../lib/plan"; +import { assembleReport } from "../lib/assemble"; +import { consumeAnalyzeRequest, getClientIdentifier } from "../lib/rate-limit"; +import type { Hotspot, Report } from "../lib/types"; + +export type AnalyzeResponse = + | { ok: true; data: Report; mode: "flow" | "heuristic" } + | { ok: false; error: string }; + +// The compact, identifier-free view of a hotspot the flow's LLM nodes reason +// over. subAccount is deliberately not included. +function forWire(h: Hotspot) { + return { + id: h.id, + provider: h.provider, + service: h.service, + serviceCategory: h.serviceCategory, + region: h.region, + regionLabel: h.regionLabel, + gridIntensity: h.gridIntensity, + usageClass: h.usageClass, + usageUnit: h.usageUnit, + usageAmount: Math.round(h.usageAmount * 100) / 100, + energyKwh: Math.round(h.energyKwh * 100) / 100, + emissionsKg: Math.round(h.emissionsKg * 100) / 100, + shareOfTotal: Math.round(h.shareOfTotal * 1000) / 1000, + cleanerRegion: h.cleanerRegion + ? { + region: h.cleanerRegion.region, + regionLabel: h.cleanerRegion.regionLabel, + gridIntensity: h.cleanerRegion.gridIntensity, + reductionPct: h.cleanerRegion.reductionPct, + crossContinent: h.cleanerRegion.crossContinent, + } + : null, + }; +} + +function unwrapPlan(raw: unknown): { diagnoses: unknown; recommendations: unknown } { + const r = raw as Record | null; + if (r && (r.status === "error" || r.message)) { + const detail = (r.message as string) ?? "unknown error"; + const code = r.statusCode ? ` (HTTP ${r.statusCode})` : ""; + throw new Error(`Lamatic rejected the request${code}: ${detail}`); + } + const payload = (r?.result as Record) ?? r ?? {}; + return { diagnoses: payload.diagnoses, recommendations: payload.recommendations }; +} + +export async function analyze(input: { + usageCsv: string; + periodLabel: string; +}): Promise { + try { + const headerList = await headers(); + const clientId = getClientIdentifier(headerList); + const rate = consumeAnalyzeRequest(clientId); + if (!rate.allowed) { + return { ok: false, error: `Rate limit exceeded. Try again in ${rate.retryAfterSeconds}s.` }; + } + + validateUploadSize(Buffer.byteLength(input.usageCsv, "utf8")); + + const rows = parseFocusCsv(input.usageCsv); + validateRowCount(rows.length); + + const lines = computeFootprintLines(rows); + const { hotspots, totalEmissionsKg, totalEnergyKwh, unrankedEmissionsKg } = buildHotspots(lines); + const currency = rows.find((r) => r.billingCurrency)?.billingCurrency ?? "USD"; + + const totals = { + totalEmissionsKg, + totalEnergyKwh, + averageGridIntensity: averageGridIntensity(lines), + unrankedEmissionsKg, + providerMix: providerMix(lines), + }; + + if (hotspots.length === 0) { + return { + ok: true, + mode: isFlowConfigured() ? "flow" : "heuristic", + data: assembleReport({ + hotspots: [], + plan: { diagnoses: [], recommendations: [], flagsByHotspot: {} }, + totals, + periodLabel: input.periodLabel, + currency, + }), + }; + } + + // Judgment: the Lamatic flow if configured, else the deterministic fallback. + let plan; + let mode: "flow" | "heuristic"; + if (isFlowConfigured()) { + const client = getLamaticClient(); + const raw = await client.executeFlow(flowIdFor("step1"), { + hotspots: hotspots.map((h) => JSON.stringify(forWire(h))), + periodLabel: input.periodLabel, + currency, + }); + const { diagnoses, recommendations } = unwrapPlan(raw); + plan = coercePlan(diagnoses, recommendations, hotspots); + mode = "flow"; + } else { + plan = localHeuristicPlan(hotspots); + mode = "heuristic"; + } + + const data = assembleReport({ hotspots, plan, totals, periodLabel: input.periodLabel, currency }); + return { ok: true, data, mode }; + } catch (e: unknown) { + if (e instanceof UploadValidationError || e instanceof FocusParseError) { + return { ok: false, error: e.message }; + } + let message = e instanceof Error ? e.message : "Analysis failed."; + if (message.includes("fetch failed")) { + message = "Could not reach Lamatic. Check LAMATIC_API_URL and your network connection."; + } else if (message.includes("HTTP 403")) { + message += " — check LAMATIC_API_KEY is an API key from Studio > Settings > API Keys, not the Project ID."; + } + return { ok: false, error: message }; + } +} diff --git a/kits/cloud-carbon-advisor/apps/app/globals.css b/kits/cloud-carbon-advisor/apps/app/globals.css new file mode 100644 index 000000000..2fb0f596e --- /dev/null +++ b/kits/cloud-carbon-advisor/apps/app/globals.css @@ -0,0 +1,118 @@ +@import "tailwindcss"; + +:root { + --bg: #071410; + --bg-elevated: #0a1a14; + --panel: #0c1d17; + --panel-2: #102821; + --border: #1a3529; + --border-strong: #244a3a; + --text: #eafaf3; + --muted: #8fb3a4; + --muted-2: #5f8072; + + --accent: #34d399; + --accent-strong: #10b981; + --accent-soft: #34d39916; + --accent-ink: #04120c; + --focus: var(--accent); + + --sev-low: #34d399; + --sev-low-soft: #34d39916; + --sev-medium: #fbbf24; + --sev-medium-soft: #fbbf2416; + --sev-high: #fb7185; + --sev-high-soft: #fb718516; + + --radius: 14px; + --radius-sm: 9px; +} + +@theme inline { + --color-bg: var(--bg); + --color-bg-elevated: var(--bg-elevated); + --color-panel: var(--panel); + --color-panel-2: var(--panel-2); + --color-edge: var(--border); + --color-edge-strong: var(--border-strong); + --color-ink: var(--text); + --color-muted: var(--muted); + --color-muted-2: var(--muted-2); + --color-accent: var(--accent); + --color-accent-strong: var(--accent-strong); + --color-accent-soft: var(--accent-soft); + --color-accent-ink: var(--accent-ink); + --color-focus: var(--focus); + --color-sev-low: var(--sev-low); + --color-sev-low-soft: var(--sev-low-soft); + --color-sev-medium: var(--sev-medium); + --color-sev-medium-soft: var(--sev-medium-soft); + --color-sev-high: var(--sev-high); + --color-sev-high-soft: var(--sev-high-soft); +} + +* { + border-color: var(--border); +} + +html, +body { + background: var(--bg); + color: var(--text); +} + +body { + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + -webkit-font-smoothing: antialiased; + background-image: + radial-gradient(1100px 620px at 8% -12%, rgba(52, 211, 153, 0.12), transparent 60%), + radial-gradient(900px 520px at 100% 0%, rgba(16, 185, 129, 0.07), transparent 55%); + background-attachment: fixed; +} + +textarea, +code, +pre { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; +} + +::selection { + background: var(--accent-soft); + color: var(--text); +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 999px; + border: 2px solid var(--bg); +} + +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.animate-fade-up { + animation: fade-up 0.35s ease-out both; +} + +@keyframes spin-slow { + to { + transform: rotate(360deg); + } +} +.animate-spin-slow { + animation: spin-slow 1.1s linear infinite; +} diff --git a/kits/cloud-carbon-advisor/apps/app/layout.tsx b/kits/cloud-carbon-advisor/apps/app/layout.tsx new file mode 100644 index 000000000..fc1b4fdca --- /dev/null +++ b/kits/cloud-carbon-advisor/apps/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Cloud Carbon Advisor", + description: + "Git blame for your cloud carbon — turn a usage export into an auditable CO₂e footprint and an impact-ranked decarbonization plan.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/kits/cloud-carbon-advisor/apps/app/page.tsx b/kits/cloud-carbon-advisor/apps/app/page.tsx new file mode 100644 index 000000000..9301bb332 --- /dev/null +++ b/kits/cloud-carbon-advisor/apps/app/page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState } from "react"; +import { Leaf, Sparkles, Cpu, ArrowRight } from "lucide-react"; +import { analyze } from "../actions/orchestrate"; +import { UploadPanel } from "../components/UploadPanel"; +import { FootprintSummary } from "../components/FootprintSummary"; +import { HotspotCard } from "../components/HotspotCard"; +import type { Report } from "../lib/types"; + +export default function Home() { + const [csv, setCsv] = useState(""); + const [periodLabel, setPeriodLabel] = useState("July 2026"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [report, setReport] = useState(null); + const [mode, setMode] = useState<"flow" | "heuristic" | null>(null); + + async function handleLoadExample() { + setError(null); + try { + const res = await fetch("/sample-usage.csv"); + if (!res.ok) throw new Error(`Example request failed: ${res.status}`); + setCsv(await res.text()); + } catch { + setError("Could not load the example file."); + } + } + + async function handleAnalyze() { + setLoading(true); + setError(null); + try { + const result = await analyze({ usageCsv: csv, periodLabel }); + if (result.ok) { + setReport(result.data); + setMode(result.mode); + } else { + setError(result.error); + setReport(null); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Something went wrong."); + setReport(null); + } finally { + setLoading(false); + } + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Cloud Carbon Advisor

+

Git blame for your cloud carbon.

+
+ {mode && ( + + {mode === "flow" ? : } + {mode === "flow" ? "Lamatic flow" : "offline heuristic"} + + )} +
+
+ +
+ + + {!report && !loading && ( +
+
+ +
+

Turn a usage export into a carbon plan

+

+ Every gram is computed by code from published grid-intensity and energy factors — the + model only decides which lever to pull, never the numbers. Press{" "} + Load example then{" "} + Analyze to see it. +

+
+ )} + + {report && ( + <> + + + {report.hotspots.length > 0 ? ( +
+
+ +

+ Carbon hotspots & decarbonization plan +

+ + {report.hotspots.length} ranked by impact + +
+
+ {report.hotspots.map((h, i) => ( + + ))} +
+
+ ) : ( +

No hotspots cleared the significance floor for this period.

+ )} + +
+ Estimates use the Cloud Carbon Footprint methodology. Figures are planning-grade, not + audited emissions. See the README for sources and limitations. +
+ + )} +
+
+ ); +} diff --git a/kits/cloud-carbon-advisor/apps/components/FootprintSummary.tsx b/kits/cloud-carbon-advisor/apps/components/FootprintSummary.tsx new file mode 100644 index 000000000..001c843b8 --- /dev/null +++ b/kits/cloud-carbon-advisor/apps/components/FootprintSummary.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Leaf, Zap, Activity, TrendingDown, Plane, TreePine, Car, Smartphone } from "lucide-react"; +import type { Report } from "../lib/types"; +import { formatMass, formatKg, formatNumber, providerLabel } from "../lib/format"; + +export function FootprintSummary({ report }: { report: Report }) { + const tonnes = report.totalEmissionsKg / 1000; + const headline = + tonnes >= 1 ? tonnes.toFixed(2) : report.totalEmissionsKg.toFixed(1); + const headlineUnit = tonnes >= 1 ? "tCO₂e" : "kgCO₂e"; + + return ( +
+
+ {/* Headline */} +
+
+ + Total footprint · {report.periodLabel} +
+
+ {headline} + {headlineUnit} +
+

{report.execSummary}

+ +
+ {report.providerMix.map((p) => ( + + {providerLabel(p.provider)} · {formatKg(p.emissionsKg)} + + ))} +
+
+ + {/* Stat tiles */} +
+ } label="Electricity" value={`${formatNumber(Math.round(report.totalEnergyKwh))} kWh`} sub="estimated consumption" /> + } label="Avg grid intensity" value={`${report.averageGridIntensity} g/kWh`} sub="emissions-weighted" /> + } + label="Projected reduction" + value={`${formatKg(report.totalProjectedReductionKg)}`} + sub={`${report.projectedReductionPct.toFixed(1)}% of total`} + highlight + /> + } + label="Region-move ceiling" + value={`${formatKg(report.cleanestRegionOpportunityKg)}`} + sub="if hotspots ran on the cleanest grid" + /> +
+
+ + {/* Equivalences */} +
+

+ {formatMass(report.totalEmissionsKg)} is roughly equivalent to +

+
+ } value={formatNumber(report.equivalences.flightsLondonNewYork)} unit="London→NYC flights" /> + } value={formatNumber(report.equivalences.treeSeedlings10yr)} unit="tree seedlings (10 yr)" /> + } value={formatNumber(report.equivalences.gasolineCarMiles)} unit="miles driven" /> + } value={formatNumber(report.equivalences.smartphoneCharges)} unit="phone charges" /> +
+
+
+ ); +} + +function Stat({ + icon, + label, + value, + sub, + highlight, +}: { + icon: React.ReactNode; + label: string; + value: string; + sub: string; + highlight?: boolean; +}) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
{sub}
+
+ ); +} + +function Equiv({ icon, value, unit }: { icon: React.ReactNode; value: string; unit: string }) { + return ( +
+
{icon}
+
{value}
+
{unit}
+
+ ); +} diff --git a/kits/cloud-carbon-advisor/apps/components/GridIntensityBar.tsx b/kits/cloud-carbon-advisor/apps/components/GridIntensityBar.tsx new file mode 100644 index 000000000..487846299 --- /dev/null +++ b/kits/cloud-carbon-advisor/apps/components/GridIntensityBar.tsx @@ -0,0 +1,55 @@ +"use client"; + +// A two-bar comparison of grid carbon intensity: where the workload runs now +// vs. the cleanest region it could move to. The visual that makes "your +// electricity is dirty, not your workload" obvious at a glance. + +type Props = { + currentLabel: string; + currentIntensity: number; + cleanerLabel?: string; + cleanerIntensity?: number; +}; + +const SCALE_MAX = 800; // gCO2e/kWh — roughly the dirtiest grid in the tables + +function pct(v: number): number { + return Math.max(2, Math.min(100, (v / SCALE_MAX) * 100)); +} + +// Grid intensity → severity tone, so a genuinely clean current region isn't +// painted red. Thresholds are rough tertiles of the grid-intensity range. +function toneFor(intensity: number): "high" | "medium" | "low" { + return intensity >= 400 ? "high" : intensity >= 150 ? "medium" : "low"; +} + +export function GridIntensityBar({ currentLabel, currentIntensity, cleanerLabel, cleanerIntensity }: Props) { + return ( +
+ + {cleanerLabel !== undefined && cleanerIntensity !== undefined && ( + + )} +
+ ); +} + +function Bar({ label, intensity, tone }: { label: string; intensity: number; tone: "high" | "medium" | "low" }) { + const color = tone === "high" ? "var(--sev-high)" : tone === "medium" ? "var(--sev-medium)" : "var(--accent)"; + return ( +
+ + {label} + +
+
+
+ + {intensity} g/kWh + +
+ ); +} diff --git a/kits/cloud-carbon-advisor/apps/components/HotspotCard.tsx b/kits/cloud-carbon-advisor/apps/components/HotspotCard.tsx new file mode 100644 index 000000000..490176361 --- /dev/null +++ b/kits/cloud-carbon-advisor/apps/components/HotspotCard.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { + Server, + HardDrive, + Network, + Box, + ArrowRight, + TrendingDown, + AlertTriangle, + Globe, + Wrench, +} from "lucide-react"; +import type { ReportHotspot, UsageClass } from "../lib/types"; +import { GridIntensityBar } from "./GridIntensityBar"; +import { driverLabel, formatKg, providerLabel, usageClassLabel } from "../lib/format"; + +function usageIcon(u: UsageClass) { + if (u === "compute" || u === "memory") return ; + if (u === "storage-ssd" || u === "storage-hdd") return ; + if (u === "network") return ; + return ; +} + +function Pill({ tone, children }: { tone: "low" | "medium" | "high" | "accent" | "neutral"; children: React.ReactNode }) { + const map: Record = { + low: "border-sev-low/30 bg-sev-low-soft text-sev-low", + medium: "border-sev-medium/30 bg-sev-medium-soft text-sev-medium", + high: "border-sev-high/30 bg-sev-high-soft text-sev-high", + accent: "border-accent/30 bg-accent-soft text-accent", + neutral: "border-edge bg-bg/50 text-muted", + }; + return ( + + {children} + + ); +} + +export function HotspotCard({ hotspot, rank }: { hotspot: ReportHotspot; rank: number }) { + const { diagnosis, recommendation, cleanerRegion } = hotspot; + const sharePct = Math.round(hotspot.shareOfTotal * 100); + + return ( +
+ {/* Header */} +
+
+ {rank} +
+
+
+ {usageIcon(hotspot.usageClass)} +

+ {hotspot.service} · {hotspot.regionLabel} +

+ {providerLabel(hotspot.provider)} + {usageClassLabel(hotspot.usageClass)} +
+

+ {hotspot.region} · account {hotspot.subAccount} ·{" "} + {Math.round(hotspot.usageAmount).toLocaleString("en-US")} {hotspot.usageUnit} +

+
+
+
{formatKg(hotspot.emissionsKg)}
+
{sharePct}% of total
+
+
+ + {/* Share bar */} +
+
+
+ + {/* Diagnosis */} +
+ = 400 ? "high" : "medium"}>{driverLabel(diagnosis.driverClass)} + confidence: {diagnosis.confidence} + {hotspot.flags.map((f) => ( + + {f} + + ))} +
+ {diagnosis.reasoning &&

{diagnosis.reasoning}

} + {diagnosis.evidence.length > 0 && ( +
    + {diagnosis.evidence.map((e, i) => ( +
  • + + {e} +
  • + ))} +
+ )} + + {/* Grid comparison */} + {cleanerRegion && ( +
+ +
+ + + Same workload in {cleanerRegion.regionLabel} emits{" "} + {cleanerRegion.reductionPct}% less + + {cleanerRegion.crossContinent && ( + + cross-region + + )} +
+
+ )} + + {/* Recommendation */} +
+
+ +
+

{recommendation.action}

+ {recommendation.rationale && ( +

{recommendation.rationale}

+ )} + {recommendation.prerequisites.length > 0 && ( +

+ Prerequisites: {recommendation.prerequisites.join("; ")} +

+ )} +
+ + −{formatKg(hotspot.projectedReductionKg)} + + + effort: {recommendation.effort} + + + risk: {recommendation.risk} + +
+
+
+
+
+ ); +} diff --git a/kits/cloud-carbon-advisor/apps/components/UploadPanel.tsx b/kits/cloud-carbon-advisor/apps/components/UploadPanel.tsx new file mode 100644 index 000000000..f7dbb0f4e --- /dev/null +++ b/kits/cloud-carbon-advisor/apps/components/UploadPanel.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { Leaf, Loader2, Upload, Play, AlertTriangle } from "lucide-react"; + +type Props = { + csv: string; + onCsvChange: (v: string) => void; + periodLabel: string; + onPeriodChange: (v: string) => void; + onAnalyze: () => void; + onLoadExample: () => void; + loading: boolean; + error: string | null; +}; + +export function UploadPanel({ + csv, + onCsvChange, + periodLabel, + onPeriodChange, + onAnalyze, + onLoadExample, + loading, + error, +}: Props) { + const rowCount = csv.trim() ? csv.trim().split("\n").length - 1 : 0; + + return ( +
+
+ +

Usage export

+ + {rowCount > 0 ? `${rowCount} row${rowCount === 1 ? "" : "s"}` : "FOCUS CSV"} + +
+ +