diff --git a/.dockerignore b/.dockerignore index 84139f4..9cc0358 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,16 +1,37 @@ +# Don't ship local artifacts into the build context. node_modules **/node_modules dist **/dist -coverage data -.git -.gitignore .env -.env.local +.env.* *.log -.DS_Store + +# Source-control + editor noise +.git +.gitignore +.github +.husky .vscode .idea +.DS_Store + +# Tests / coverage +coverage +**/coverage +**/*.test.ts +**/*.test.tsx +**/__tests__ + +# Credentials, never bake into images claude-credentials.json +*.credentials.json + +# Local docs that aren't needed inside the image (the demo-assets snapshot +# lives under apps/api/demo-assets and is included implicitly). docs/superpowers +README.md + +# pnpm artifacts that get re-derived inside the image +.pnpm-store diff --git a/.env.example b/.env.example index 45d0f2d..8cff8a5 100644 --- a/.env.example +++ b/.env.example @@ -11,26 +11,44 @@ ENABLE_PR_FLOW=false # investigation) and /docs/*.md (for RAG). # Prod: leave PRODUCT_REPO_PATH empty and set PRODUCT_REPO_URL; # the entrypoint clones the repo at boot. -PRODUCT_REPO_PATH=/Users/jen/projects/ai_support_agents_product +PRODUCT_REPO_PATH= PRODUCT_REPO_URL= +# Base branch the openFixPR worktree forks from when proposing a fix. +# Defaults to "main". Override when the trunk lives on a non-default +# branch (common during early development). PRs target this branch too. +PRODUCT_REPO_BASE_BRANCH=main -# RAG overrides — leave unset to use /docs + data/rag-index.json +# RAG overrides - leave unset to use /docs + data/rag-index.json DOCS_DIR= RAG_CACHE_PATH= +# SQLite session store. Defaults to data/sessions.db (next to data/rag-index.json). +# Sessions persist across api restarts. Set to ":memory:" for ephemeral mode. +DATABASE_PATH= + +# Demo fixtures dir. Empty = no canned demo responses; the agent's mock +# fallback returns a generic "running in demo mode" reply. Cloud-Run +# deploys point this at a product-specific fixtures folder so visitors see +# the full pipeline flow without burning live LLM tokens. +FIXTURES_DIR= + # CORS WIDGET_ALLOWED_ORIGINS= DASHBOARD_ORIGIN= -# Shortcut — if SHORTCUT_API_TOKEN is unset, the api uses the mock client. +# Shortcut - if SHORTCUT_API_TOKEN is unset, the api uses the mock client. # Get a token at https://app.shortcut.com/settings/account/api-tokens -# SHORTCUT_WORKFLOW_STATE_ID is the numeric id of the workflow state new bug -# tickets should land in (often "Ready for Dev"). Find it via: +# SHORTCUT_WORKSPACE_SLUG is the workspace identifier in the user-facing +# URL (e.g. https://app.shortcut.com//...). The mock client uses it +# to render realistic ticket URLs in screencasts. +# SHORTCUT_WORKFLOW_STATE_ID is the numeric id of the workflow state new +# bug tickets should land in (often "Ready for Dev"). Find it via: # GET https://api.app.shortcut.com/api/v3/workflows SHORTCUT_API_TOKEN= -SHORTCUT_WORKSPACE= +SHORTCUT_BASE_URL=https://api.app.shortcut.com/api/v3 +SHORTCUT_WORKSPACE_SLUG= SHORTCUT_WORKFLOW_STATE_ID= -# GitHub — if unset OR ENABLE_PR_FLOW=false, uses mock PR client +# GitHub - if unset OR ENABLE_PR_FLOW=false, uses mock PR client GITHUB_TOKEN= GITHUB_REPO= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..14e6547 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,90 @@ +# AI Support Engineer - Cloud Run image (mock-mode demo). +# +# Two stages: build everything in the workspace, then copy only what the +# api needs into a slim runtime. The widget's loader + iframe SPA are +# emitted to repo-root /dist/widget/ at build time and served as static +# assets by the api. + +# ──────────────────────────────────────────────────────────────────── +# Stage 1 - builder +# ──────────────────────────────────────────────────────────────────── +FROM node:22-slim AS builder + +# better-sqlite3 + onnxruntime + transformers compile native bindings. +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + make \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Enable pnpm via corepack and pin to the workspace's declared version. +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/shared/package.json packages/shared/ +COPY apps/api/package.json apps/api/ +COPY apps/dashboard/package.json apps/dashboard/ +COPY apps/widget/package.json apps/widget/ + +RUN pnpm install --frozen-lockfile + +# Source - kept after install so dep changes don't bust the source layer. +COPY tsconfig.base.json biome.json ./ +COPY packages packages +COPY apps apps + +# Build order: shared → api (consumes shared) → widget (loader + app SPA) +# → dashboard (the bundled session-replay UI served at /dashboard/). +RUN pnpm --filter @ai-support/shared build \ + && pnpm --filter @ai-support/api build \ + && pnpm --filter @ai-support/widget build \ + && pnpm --filter @ai-support/dashboard build + +# ──────────────────────────────────────────────────────────────────── +# Stage 2 - runtime +# ──────────────────────────────────────────────────────────────────── +FROM node:22-slim AS runtime + +WORKDIR /app +ENV NODE_ENV=production \ + PORT=8080 + +# Copy the workspace metadata so node module resolution works against +# pnpm's symlinked node_modules tree. +COPY --from=builder /app/package.json /app/pnpm-workspace.yaml ./ +COPY --from=builder /app/node_modules ./node_modules + +# Built workspace packages. The per-workspace node_modules dirs are +# pnpm symlink farms pointing into /app/node_modules/.pnpm - they're +# tiny but required for module resolution at runtime. +COPY --from=builder /app/packages/shared/dist packages/shared/dist +COPY --from=builder /app/packages/shared/package.json packages/shared/package.json +COPY --from=builder /app/packages/shared/node_modules packages/shared/node_modules + +# Built api. +COPY --from=builder /app/apps/api/dist apps/api/dist +COPY --from=builder /app/apps/api/package.json apps/api/package.json +COPY --from=builder /app/apps/api/node_modules apps/api/node_modules + +# Demo assets (docs + fixtures snapshot from the product repo). +COPY --from=builder /app/apps/api/demo-assets apps/api/demo-assets + +# Built widget assets (loader.js + iframe SPA), served by the api at +# /widget/loader.js and /widget/* respectively. +COPY --from=builder /app/dist/widget dist/widget + +# Built dashboard SPA, served by the api at /dashboard/*. +COPY --from=builder /app/dist/dashboard dist/dashboard + +EXPOSE 8080 + +# Default DOCS_DIR + FIXTURES_DIR point at the bundled snapshot. Override +# in deploys that ship against a different product. WIDGET_ALLOWED_ORIGINS +# is set per-deployment via Cloud Run env vars (not baked). +ENV DOCS_DIR=/app/apps/api/demo-assets/docs \ + FIXTURES_DIR=/app/apps/api/demo-assets/fixtures \ + DATABASE_PATH=:memory: \ + DEMO_MODE=true + +CMD ["node", "apps/api/dist/server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..001a517 --- /dev/null +++ b/LICENSE @@ -0,0 +1,38 @@ +MIT License + +Copyright (c) 2026 Cash-Codes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +"Commons Clause" License Condition v1.0 + +The Software is provided to you by the Licensor under the License, as defined +below, subject to the following condition. + +Without limiting other conditions in the License, the grant of rights under +the License will not include, and the License does not grant to you, the +right to Sell the Software. + +For purposes of the foregoing, "Sell" means practicing any or all of the +rights granted to you under the License to provide to third parties, for a +fee or other consideration (including without limitation fees for hosting or +consulting/support services related to the Software), a product or service +whose value derives, entirely or substantially, from the functionality of the +Software. Any license notice or attribution required by the License must also +include this Commons Clause License Condition notice. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0b99bc3..b17449a 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,575 @@ # AI Support Engineer -Agentic support system. User sends a bug report → docs RAG → optional code investigation → explanation + workaround → Shortcut ticket → optional fix PR. +An embeddable AI support agent that reads your product docs, investigates your codebase when docs aren't enough, drafts a workaround for the user and - when the issue is a confirmed bug - opens a Shortcut ticket and a GitHub PR with the fix. Drop one ` +``` + +The loader injects a floating chat avatar + sandboxed iframe. The iframe is served from the api's origin (so its fetches to `/session/init`, `/chat`, etc. are same-origin and need no extra CORS). The host page's CORS is governed by `WIDGET_ALLOWED_ORIGINS` - make sure your host's origin is in the CSV. + +`data-suggestions` is optional. When present, the empty-state shows clickable chips that pre-fill the message. When absent, the empty-state shows just the welcome text. + +### 7. Trigger your first run + +Visit `http://localhost:5175` (Pulsefile dev) or whichever host you embedded into. Click the chat avatar, click a chip - or type a question. Watch the api logs: + +``` +[20:42:42] phase completed { phase: "intake", durationMs: 0 } +[20:42:42] phase completed { phase: "docsRetrieval", durationMs: 12 } +[20:42:42] claude-cli: spawning { tag: "router" } +[20:43:09] claude-cli: completed { tag: "router", stdoutBytes: 1449 } +[20:43:09] claude-cli: spawning { tag: "investigate" } +[20:43:44] claude-cli: completed { tag: "investigate", stdoutBytes: 1585 } +[20:43:55] claude-cli: completed { tag: "synthesize", stdoutBytes: 862 } +[20:43:56] shortcut: story created { ticketId: 76, ticketUrl: "https://..." } +[20:43:56] worktree: created { branch: "support/...", baseBranch: "main" } +[20:45:17] claude-cli: completed { tag: "fix-agent", stdoutBytes: 853 } +[20:45:17] github: PR opened { branch: "support/...", prUrl: "https://..." } +``` + +Replay the session at `http://localhost:5174/dashboard/`. Each phase is timed and traceable; ticket and PR URLs are linked. + +### 8. Manual smoke test (no UI) + +```bash +# 1. Create a session +SESSION=$(curl -s -X POST http://localhost:8080/session/init \ + -H "Content-Type: application/json" \ + -d '{"product":"pulsefile"}' | jq -r .sessionId) + +# 2. Stream a question (SSE - Ctrl-C to stop) +curl -N -X POST http://localhost:8080/chat \ + -H "Content-Type: application/json" \ + -d "{\"sessionId\":\"${SESSION}\",\"message\":\"My HTTP check shows ok but content-match is failing - is that intended?\"}" + +# 3. Read the persisted session +curl -s http://localhost:8080/sessions/${SESSION} | jq . +``` + +## Docker (local testing) + +The same image is what deploys to Cloud Run. + +### Build + +```bash +docker build -t agent-api:local . +``` + +The Dockerfile is a multi-stage build on `node:22-slim`: + +- **builder** - installs `python3 / make / g++` (for `better-sqlite3` + `onnxruntime` native bindings), runs `pnpm install --frozen-lockfile`, then builds shared → api → widget → dashboard in order. +- **runtime** - copies only `node_modules`, the built `dist/` of each workspace and the demo-assets snapshot. No build toolchain in the runtime image. + +### Run + +```bash +docker run --rm -p 8080:8080 \ + -e PRODUCT_REPO_PATH= \ + -e DEMO_MODE=true \ + -e DATABASE_PATH=:memory: \ + agent-api:local +``` + +Mock mode kicks in (no Claude credentials mounted, no Shortcut / GitHub tokens). Hit `http://localhost:8080/widget/`, `http://localhost:8080/dashboard/`, `http://localhost:8080/health`. + +To exercise live Claude inside Docker, mount your credentials in: + +```bash +# (macOS) Extract from Keychain to a flat file once +security find-generic-password -s "Claude Code-credentials" -w \ + > /tmp/claude-credentials-content +mkdir -p /tmp/claude-creds +echo '{"claudeAiOauth":'"$(cat /tmp/claude-credentials-content)"'}' \ + > /tmp/claude-creds/.credentials.json + +docker run --rm -p 8080:8080 \ + -v /tmp/claude-creds/.credentials.json:/root/.claude/.credentials.json:ro \ + -v /Users/you/projects/pulsefile:/repos/pulsefile \ + -e PRODUCT_REPO_PATH=/repos/pulsefile \ + agent-api:local +``` + +## Cloud Run Deployment + +Mock-mode deploy - bundles the demo-assets snapshot, runs without Claude / Shortcut / GitHub credentials. Public, scale-to-zero, ~£0/month at idle. + +### One-shot + +```bash +./scripts/deploy-cloud-run.sh +``` + +The script: + +1. Re-snapshots `/docs` + `fixtures/mock-responses` into `apps/api/demo-assets/` so the image always matches the current product repo state. +2. Enables required APIs (`artifactregistry`, `cloudbuild`, `run`) idempotently. +3. Creates the Artifact Registry repo if missing. +4. `gcloud builds submit` - remote linux/amd64 build, ~3-5 min. +5. `gcloud run deploy` with the env vars below. + +### Environment overrides + +```bash +PROJECT_ID=ai-support-engineer \ +REGION=us-central1 \ +SERVICE_NAME=agent-api \ +ALLOWED_ORIGINS="https://your-host.example.com,http://localhost:5175" \ +./scripts/deploy-cloud-run.sh +``` + +`ALLOWED_ORIGINS` becomes `WIDGET_ALLOWED_ORIGINS` on the deployed service. The script uses `^@^` as the gcloud env-var delimiter so a CSV value like `https://a,http://b` survives parsing intact. + +### Tightening CORS after the host product deploys + +The agent allows any origin when `WIDGET_ALLOWED_ORIGINS` is empty (dev default). Once your host product has a public URL, restrict the allow-list: + +```bash +ALLOWED_ORIGINS="https://your-host.example.com" \ +SKIP_SNAPSHOT=1 \ +./scripts/deploy-cloud-run.sh +``` + +The agent's own origin is **always** allowed automatically (the iframe at `/widget/` makes same-origin asset requests with ` diff --git a/fixtures/mock-responses/basket-push-empty.json b/fixtures/mock-responses/basket-push-empty.json deleted file mode 100644 index fabe5ba..0000000 --- a/fixtures/mock-responses/basket-push-empty.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "slug": "basket-push-empty", - "keywords": [ - "basket is empty", - "nothing in basket", - "empty basket", - "pushed but empty", - "tesco is empty", - "asda is empty", - "basket push", - "items not in tesco", - "nothing there" - ], - "router": { - "escalate": true, - "rationale": "Docs describe the symptom but the 'push succeeds with 200 but drops items' behavior is a specific bug we've located in the retailer client code.", - "confidence": "high" - }, - "codeInvestigation": { - "rootCause": "The retailer push client in src/retailers/tesco.ts treats any 2xx response as a success, but Tesco's API returns HTTP 200 with an empty body when the session cookie has expired. Items are silently dropped. ASDA exhibits the same pattern.", - "affectedFiles": [ - "src/retailers/tesco.ts", - "src/retailers/asda.ts", - "src/retailers/sessionStatus.ts" - ], - "workaround": "Go to Settings → Supermarkets and click Reconnect on the retailer, then re-run the push. The new session will populate the basket correctly.", - "confidence": "high" - }, - "resolution": { - "explanation": "This is a bug in how we handle expired retailer sessions during basket push. Tesco and ASDA's APIs return HTTP 200 with an empty response body when the session cookie has expired, but our code treats any 2xx status as a successful push and discards the items. The fix is to detect the empty-items response and surface it as an expired-session error instead of a silent success.", - "workaround": "Open Settings → Supermarkets, click Reconnect next to Tesco or ASDA, and run the push again. The basket should populate within a few seconds.", - "confidence": "high", - "citations": [ - "known-issues.md", - "troubleshooting.md", - "accounts-and-auth.md" - ] - }, - "ticket": { - "title": "Retailer: treat 200-with-empty-body as expired session, not success", - "body": "**Problem**\nUsers report that pushing a basket to Tesco or ASDA shows a success toast in SnapBasket, but the retailer site is empty when they switch to complete checkout.\n\n**Root cause**\n`src/retailers/tesco.ts` and `src/retailers/asda.ts` treat any 2xx response as a successful push. Both retailers return HTTP 200 with an empty body when the session cookie has expired, so items are silently dropped.\n\n**Suggested fix**\n1. In the retailer clients, check the response body for an `items: []` (or equivalently empty payload) and treat that as a session-expired signal.\n2. Route that signal through `sessionStatus.ts` so the UI surfaces the 'Reconnect ' banner and the push is aborted before the success toast.\n3. Add integration tests for both retailers that mock a 200-empty response and assert an expired-session error is raised.\n\n**Affected files**\n- src/retailers/tesco.ts\n- src/retailers/asda.ts\n- src/retailers/sessionStatus.ts\n\n**Severity**: high — silent data loss from the user's perspective." - }, - "pr": { - "branch": "fix/retailer-expired-session-detection", - "title": "fix(retailers): detect expired session on 200-empty response", - "summary": "Tesco and ASDA return HTTP 200 with an empty body on expired session cookies; previously we treated that as a successful push. This PR checks the response body and raises an ExpiredSessionError, which propagates to the UI as a 'Reconnect' banner." - } -} diff --git a/fixtures/mock-responses/heic-rotation.json b/fixtures/mock-responses/heic-rotation.json deleted file mode 100644 index 6405ead..0000000 --- a/fixtures/mock-responses/heic-rotation.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "slug": "heic-rotation", - "keywords": [ - "rotated", - "rotation", - "sideways", - "upside down", - "heic", - "iphone photo", - "photo is rotated" - ], - "router": { - "escalate": false, - "rationale": "Docs describe this known behavior and a clear workaround; no code investigation needed.", - "confidence": "high", - "draftAnswer": "Rotated iPhone photos are a known HEIC → JPEG conversion issue. The simplest fix is to rotate the photo on your phone before uploading, or export it as PNG from the Photos app. A permanent fix to preserve EXIF orientation is in progress." - }, - "resolution": { - "explanation": "This is a known issue with our HEIC image converter: iPhone photos taken in portrait mode occasionally lose their EXIF orientation metadata during conversion and render rotated. It does not affect OCR accuracy, but the preview is misleading.", - "workaround": "Rotate the image on your phone (Photos app → Edit → Rotate) before uploading, or export the image as PNG first. The OCR will read the rotated version correctly.", - "confidence": "high", - "citations": ["known-issues.md", "troubleshooting.md"] - } -} diff --git a/fixtures/mock-responses/quantity-ocr.json b/fixtures/mock-responses/quantity-ocr.json deleted file mode 100644 index a446ee8..0000000 --- a/fixtures/mock-responses/quantity-ocr.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "slug": "quantity-ocr", - "keywords": [ - "6 eggs", - "quantity wrong", - "wrong quantity", - "only added 1", - "said 6 but", - "quantity is 1", - "quantities are wrong", - "handwritten digit" - ], - "router": { - "escalate": true, - "rationale": "Docs mention this class of issue but the specific digit-to-letter misread pattern needs code inspection to confirm the parser behavior.", - "confidence": "medium" - }, - "codeInvestigation": { - "rootCause": "The quantity parser in ocr/parseQuantity.ts discards any token that contains a non-numeric character, which silently drops misread digits like '6' read as 'b'. The fallback path then defaults the quantity to 1 without surfacing that the token was rejected.", - "affectedFiles": [ - "src/ocr/parseQuantity.ts", - "src/ocr/confidence.ts", - "src/ui/ReviewTable.svelte" - ], - "workaround": "Edit the quantity inline in the review table before pushing the basket — cells become editable on click.", - "confidence": "medium" - }, - "resolution": { - "explanation": "When a handwritten digit is misread by the OCR as a letter (commonly 6→b, 7→1), our quantity parser rejects the mixed token and silently falls back to a default of 1. The underlying OCR confidence signal exists but isn't surfaced on the review row, so it looks like a successful parse. The fix would be to (a) detect likely-digit-letter confusions and retry, and (b) mark the row as low-confidence so users are prompted to check it.", - "workaround": "Every row in the review table is editable on click — adjust the quantity manually before pushing the basket to Tesco or ASDA.", - "confidence": "medium", - "citations": [ - "known-issues.md", - "ocr-and-items.md", - "troubleshooting.md" - ] - }, - "ticket": { - "title": "OCR: handwritten quantity digits silently default to 1 on digit-letter misreads", - "body": "**Problem**\nUsers report that handwritten quantities like \"6 eggs\" land in the review table as quantity 1 with no indication that the digit was misread.\n\n**Probable root cause**\n`src/ocr/parseQuantity.ts` discards any token containing a non-numeric character. When the OCR pass misreads `6` as `b`, the token is rejected and the fallback default of 1 is used, without downgrading the row's confidence badge.\n\n**Suggested fix**\n1. In `parseQuantity.ts`, detect common digit-letter confusions (`b↔6`, `l↔1`, `O↔0`, `S↔5`) and retry the parse against the digit-interpretation.\n2. When the parser falls back to the default, emit a `quantityFallback: true` flag on the row so `confidence.ts` can downgrade it to Low.\n3. In `ReviewTable.svelte`, render a small warning badge on rows with `quantityFallback`.\n\n**Affected files**\n- src/ocr/parseQuantity.ts\n- src/ocr/confidence.ts\n- src/ui/ReviewTable.svelte\n\n**Severity**: medium — user-facing but with an obvious workaround (edit inline)." - } -} diff --git a/package.json b/package.json index c70ef2f..81f266b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "ai-support-engineer", "version": "0.1.0", "private": true, - "description": "Agentic support system — chat UI, docs RAG, code investigation, ticket/PR flow", + "description": "Agentic support system - chat UI, docs RAG, code investigation, ticket/PR flow", "type": "module", "engines": { "node": ">=22" @@ -26,6 +26,7 @@ "pnpm": { "onlyBuiltDependencies": [ "@biomejs/biome", + "better-sqlite3", "esbuild", "onnxruntime-node", "protobufjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e311187..606a2cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@xenova/transformers': specifier: ^2.17.2 version: 2.17.2 + better-sqlite3: + specifier: ^11.5.0 + version: 11.10.0 cors: specifier: ^2.8.5 version: 2.8.6 @@ -45,6 +48,9 @@ importers: specifier: ^3.23.8 version: 3.25.76 devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.12 + version: 7.6.13 '@types/cors': specifier: ^2.8.17 version: 2.8.19 @@ -925,6 +931,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -1147,10 +1156,16 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + better-sqlite3@11.10.0: + resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -1485,6 +1500,9 @@ packages: picomatch: optional: true + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -3031,6 +3049,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 22.19.17 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -3262,8 +3284,17 @@ snapshots: baseline-browser-mapping@2.10.20: {} + better-sqlite3@11.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + binary-extensions@2.3.0: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -3651,6 +3682,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 diff --git a/screenshots/dashboard.png b/screenshots/dashboard.png new file mode 100644 index 0000000..805aaa5 Binary files /dev/null and b/screenshots/dashboard.png differ diff --git a/screenshots/github_pr.png b/screenshots/github_pr.png new file mode 100644 index 0000000..cc59ca2 Binary files /dev/null and b/screenshots/github_pr.png differ diff --git a/screenshots/main.png b/screenshots/main.png new file mode 100644 index 0000000..76c3639 Binary files /dev/null and b/screenshots/main.png differ diff --git a/screenshots/shortcut_ticket.png b/screenshots/shortcut_ticket.png new file mode 100644 index 0000000..9d7d7db Binary files /dev/null and b/screenshots/shortcut_ticket.png differ diff --git a/scripts/deploy-cloud-run.sh b/scripts/deploy-cloud-run.sh new file mode 100755 index 0000000..63aedb6 --- /dev/null +++ b/scripts/deploy-cloud-run.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Builds + deploys the agent api to Cloud Run as a mock-mode demo. +# +# Prerequisites: +# • gcloud authenticated (`gcloud auth login`) +# • The target project exists and the user has Cloud Run + Artifact Registry roles +# • An Artifact Registry repo exists (created on first run if missing) +# +# Usage: +# ./scripts/deploy-cloud-run.sh +# +# Environment overrides: +# PROJECT_ID - GCP project (default: ai-support-engineer) +# REGION - Cloud Run region (default: us-central1) +# SERVICE_NAME - Cloud Run service name (default: agent-api) +# AR_REPO - Artifact Registry repo (default: containers) +# ALLOWED_ORIGINS - comma-separated CORS origins for the widget +# (set after the host product deploys) + +PROJECT_ID="${PROJECT_ID:-ai-support-engineer}" +REGION="${REGION:-us-central1}" +SERVICE_NAME="${SERVICE_NAME:-agent-api}" +AR_REPO="${AR_REPO:-containers}" +ALLOWED_ORIGINS="${ALLOWED_ORIGINS:-}" +IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${AR_REPO}/${SERVICE_NAME}" + +cd "$(dirname "$0")/.." + +# Re-snapshot demo assets so the image always matches the current product +# repo's docs + fixtures. Skip with SKIP_SNAPSHOT=1 if you've already run it. +if [ "${SKIP_SNAPSHOT:-0}" != "1" ]; then + ./scripts/snapshot-demo-assets.sh +fi + +# Enable required APIs the first time we deploy in a project. Idempotent. +REQUIRED_APIS=( + "artifactregistry.googleapis.com" + "cloudbuild.googleapis.com" + "run.googleapis.com" +) +for api in "${REQUIRED_APIS[@]}"; do + if ! gcloud services list --project="$PROJECT_ID" \ + --enabled --filter="config.name:$api" --format='value(config.name)' \ + | grep -q "$api"; then + echo "enabling $api..." + gcloud services enable "$api" --project="$PROJECT_ID" + fi +done + +# Ensure Artifact Registry repo exists (idempotent). +if ! gcloud artifacts repositories describe "$AR_REPO" \ + --project="$PROJECT_ID" --location="$REGION" >/dev/null 2>&1; then + echo "creating Artifact Registry repo $AR_REPO in $REGION..." + gcloud artifacts repositories create "$AR_REPO" \ + --project="$PROJECT_ID" \ + --location="$REGION" \ + --repository-format=docker +fi + +# Build remotely on Cloud Build (linux/amd64, no local platform mismatch). +echo "building image: $IMAGE" +gcloud builds submit \ + --project="$PROJECT_ID" \ + --tag="$IMAGE" \ + . + +# Deploy to Cloud Run. Public widget - unauthenticated invocations allowed. +# Use ^@^ as the K=V pair separator so values containing commas (a CSV +# list of allowed widget origins) survive parsing intact. +ENV_PAIRS="NODE_ENV=production@LOG_LEVEL=info@DEMO_MODE=true@ENABLE_PR_FLOW=false@DATABASE_PATH=:memory:" +if [ -n "$ALLOWED_ORIGINS" ]; then + ENV_PAIRS+="@WIDGET_ALLOWED_ORIGINS=${ALLOWED_ORIGINS}" +fi +DEPLOY_ARGS=( + --project="$PROJECT_ID" + --region="$REGION" + --image="$IMAGE" + --platform=managed + --allow-unauthenticated + --port=8080 + --cpu=1 + --memory=1Gi + --concurrency=80 + --min-instances=0 + --max-instances=3 + --timeout=300 + --set-env-vars="^@^${ENV_PAIRS}" +) + +echo "deploying to Cloud Run: $SERVICE_NAME ($REGION)" +gcloud run deploy "$SERVICE_NAME" "${DEPLOY_ARGS[@]}" + +# Print the service URL - paste this into the host product's