diff --git a/.Jules/palette.md b/.Jules/palette.md deleted file mode 100644 index 2479b44d9..000000000 --- a/.Jules/palette.md +++ /dev/null @@ -1,7 +0,0 @@ -## 2024-07-14 - Scrubber Keyboard Accessibility -**Learning:** Adding keyboard event listeners (like `onKeyDown`) to custom interactive elements (like a `div` acting as a scrubber/slider) doesn`t automatically expose those shortcuts to screen readers. -**Action:** Always add `aria-keyshortcuts` to custom ARIA widgets (like `role="slider"`) to announce available keyboard commands (e.g., "ArrowLeft ArrowRight Home End") when the element receives focus. - -## 2026-07-13 - Search Input Accessibility -**Learning:** Search inputs still need an explicit programmatic label when the only visible prompt is a placeholder, but a submit button with visible text like `Go` should usually rely on that visible text for its accessible name so voice-control users can activate it by name. -**Action:** Add a real label (or equivalent programmatic name) to placeholder-only search inputs, and only add an `aria-label` to short-text submit buttons when it includes the visible button text. diff --git a/.dockerignore b/.dockerignore index f165f7c99..3c3a2ed6e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,12 +1,10 @@ # Node & Frontend node_modules/ -# Keep apps/web for the build, but ignore other apps if any -apps/* +apps/ !apps/web/ apps/web/node_modules/ apps/web/.next/ .next/ -.turbo/ # Chrome / NotebookLM browser profiles (~1GB) notebooklm_chrome_profile/ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d886cf5a4..d37c7fa39 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,9 +6,6 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 10 - ignore: - - dependency-name: "eslint" - versions: [">=10"] groups: npm-minor-patch: update-types: ["minor", "patch"] @@ -18,9 +15,6 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 10 - ignore: - - dependency-name: "eslint" - versions: [">=10"] # Python backend dependencies. - package-ecosystem: "pip" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffe7b6359..07ea653c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,40 +11,16 @@ permissions: actions: read jobs: - guards: - # Fail fast on the class of breakage that shipped to main un-caught: - # committed merge-conflict markers and import-time Python SyntaxErrors. - # (main previously carried unresolved markers in 10 files because the - # pipeline had no syntax gate — see PR #736.) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - name: No committed merge-conflict markers - run: | - # Opening/closing conflict sentinels always carry a label after the - # space, so this never matches decorative "=======" underlines. - if git grep -nE '^(<<<<<<<|>>>>>>>) ' -- . ':(exclude).github/workflows/ci.yml'; then - echo "::error::Committed merge-conflict markers found (see matches above)." - exit 1 - fi - echo "No conflict markers found." - - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - name: Python source compiles (no import-time SyntaxErrors) - run: python -m compileall -q src/ - build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@v6 with: node-version: "22" cache: "npm" - run: npm install --legacy-peer-deps - name: TypeScript type-check (apps/web) - continue-on-error: true run: cd apps/web && npm run type-check - name: ESLint (apps/web) run: cd apps/web && npm run lint @@ -73,7 +49,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@v6 with: node-version: "22" cache: "npm" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4948f9539..5ee0e3a0f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,14 +32,14 @@ jobs: - name: Cache dependencies (Python) if: matrix.language == 'python' - uses: actions/cache@v6 + uses: actions/cache@v5 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }} - name: Cache dependencies (Node) if: matrix.language == 'javascript' - uses: actions/cache@v6 + uses: actions/cache@v5 with: # Cache the npm download cache, not node_modules: this is an npm # workspaces repo, so deps hoist to the root and apps/web/node_modules diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 59d609d81..1aa688a1e 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v3 + uses: dependabot/fetch-metadata@v2 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - uses: actions/github-script@v9 diff --git a/.github/workflows/deploy-cloud-run.yml b/.github/workflows/deploy-cloud-run.yml index 6e892c2ed..375fc4c79 100644 --- a/.github/workflows/deploy-cloud-run.yml +++ b/.github/workflows/deploy-cloud-run.yml @@ -186,7 +186,7 @@ jobs: uses: actions/checkout@v7 - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 with: scan-type: 'fs' scan-ref: '.' diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 547c4b356..4d51b1b1e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -48,7 +48,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@v6 with: node-version: '22' cache: 'npm' @@ -138,7 +138,7 @@ jobs: echo "failed=$FAILED" >> $GITHUB_OUTPUT - name: Post PR comment with results - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name == 'pull_request' uses: actions/github-script@v9 with: script: | diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 89d39177c..71a82c436 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@v6 with: node-version: '22' cache: 'npm' @@ -76,7 +76,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Cache Trivy DB - uses: actions/cache@v6 + uses: actions/cache@v5 with: path: ~/.cache/trivy key: trivy-db-${{ github.run_id }} @@ -86,7 +86,7 @@ jobs: - name: Build image for scanning run: docker build -t eventrelay:test -f Dockerfile . - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 with: image-ref: 'eventrelay:test' format: 'sarif' @@ -102,7 +102,7 @@ jobs: with: sarif_file: 'trivy-results.sarif' - name: Generate human-readable report - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 if: always() with: image-ref: 'eventrelay:test' diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml index 9d8765068..d4b69bdf8 100644 --- a/.github/workflows/verification.yml +++ b/.github/workflows/verification.yml @@ -37,7 +37,7 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -81,7 +81,7 @@ jobs: timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@v4 - name: Set up Python 3.11 uses: actions/setup-python@v6 @@ -145,7 +145,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@v4 - name: Set up Python 3.11 uses: actions/setup-python@v6 @@ -215,7 +215,7 @@ jobs: timeout-minutes: 5 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@v4 - name: Check for blocking sleep in async functions run: | diff --git a/.gitignore b/.gitignore index f148f1777..f0290609a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,45 +1,58 @@ !.env.example +!.gitkeep # --- Archives --- # --- Build Artifacts --- +# --- Committed build/cache artifacts that must never be tracked --- # --- Data & Models --- -data/knowledge_base.json -data/jobs/ -data/mcp_contexts/ # --- Dependencies --- # --- Editors & IDEs --- +# --- Extra secret hardening (defense-in-depth; gitleaks is the enforcing gate) --- # --- Frameworks --- +# --- Generated Reports & Data Dumps --- # --- Git & System --- # --- Ignore nested git repositories that aren't properly configured as submodules --- # --- Jupyter --- # --- Logs & Temp --- -# --- Project Specific --- -# --- Generated Reports & Data Dumps --- -CREDENTIALS_REPORT.json -IMPLEMENTATION_COMPLETE.md -autonomous_processing_report_*.json -comments_*.json -transcript_action_result.json -dashboard_test.html # --- Loose root scripts (must live under src/, scripts/, or tools/) --- -/analyze_comments.py -/fetch_comments.py -/verify_enhancements.py +# --- Project Specific --- # --- Secrets (CRITICAL) --- +# --- Security audit artifacts (keep local; never publish a vuln map to a public repo) --- +# AI Studio exports +# Chrome / NotebookLM browser profiles (massive, never belong in repo) +# Claude agent worktrees (auto-generated, never commit) +# Compiled output for Supabase edge functions. Deno runs the .ts source +# Database files (unless tracked intentionally) +# Empty/orphaned tool directories +# Firebase Data Connect local PGlite emulator cache (~27M of binary DB files) +# Generated logs (keep .gitkeep files) +# Playwright artifacts +# Root test-coverage artifact (the coverage/ dir is ignored above, but this stray file slipped through) +# Runtime pipeline audit logs (written by pipeline_audit_store at runtime) +# Saved Google AI Studio webpage dump (vendored HTML/JS/CSS, ~23M) +# Test artifacts +# TypeScript incremental build cache +# Webpack cache artifacts +# directly; these are stale tsc artifacts that must not be committed or deployed. +**/.dataconnect/pgliteData/ **/production-secrets.json **/secrets.json +*-remix.xml *.bak *.cert *.code-workspace *.coverage -coverage.xml *.crt -*.egg-info/ *.db +*.db-journal +*.db-wal +*.egg-info/ *.gguf *.ipynb *.key *.log +*.pack.gz.old +*.pack.old *.pem *.pyc *.pyd @@ -53,53 +66,42 @@ coverage.xml *.temp *.tgz *.tmp +*.tsbuildinfo *REAL_API*.md *_secret_*.json *api*key*.txt *credential*.txt +*private*.txt +*secret*.txt *~ .AppleDouble .DS_Store - -# Generated logs (keep .gitkeep files) -*.log -!.gitkeep -autonomous_processing_report_*.json - -# Database files (unless tracked intentionally) -performance_monitoring.db -*.db-journal -*.db-wal - -# Webpack cache artifacts -*.pack.old -*.pack.gz.old - -# AI Studio exports -ai-studio-*.xml -*-remix.xml - -# Empty/orphaned tool directories -.kombai/ -workflow_results/ .LSOverride .aiexclude .backup_*/ .build/ .cache/ +.claude/worktrees/ .directory .dmypy.json +.env .env* +.env*.local .gemini/*.log -.gemini/tmp/ .gemini/auth/ .gemini/cache/ +.gemini/tmp/ .git .gitignore +.gstack/ .idea/ .ipynb_checkpoints/ +.kombai/ .mypy_cache/ +.next/ .npm/ +.poc-runtime.db +.poc-venv/ .pyre/ .pytest_cache/ .ropeproject @@ -110,19 +112,29 @@ workflow_results/ .spyproject .turbo/ .venv/ -.poc-venv/ -.poc-runtime.db .venv_prod_verify/ +.vercel .vscode/ .webassets-cache .yarn/ +/analyze_comments.py +/fetch_comments.py +/verify_enhancements.py +CREDENTIALS_REPORT.json Desktop.ini GAP_FIXING_WORKFLOW_REPORT.json +IMPLEMENTATION_COMPLETE.md Thumbs.db +UVAI_Digital_Refinery_Blueprint.pdf __pycache__/ _archive/ ai-edge-torch/ +ai-studio-*.xml api_usage_data.json +apps/web/playwright-report/ +apps/web/test-results/ +autonomous_processing_report_*.json +backend.log build/ build_extensions/ build_extensions/uvai-extensions/ai-integrations/MiniCPM-o/ @@ -131,14 +143,26 @@ celerybeat-schedule celerybeat.pid chrome_build/ client_secret_*.json +comments_*.json cost_report.json +coverage.json +coverage.xml coverage/ curl_output.txt +dashboard_test.html +data/audit/*.jsonl +data/jobs/ +data/knowledge_base.json +data/mcp_contexts/ +dataconnect/.dataconnect/ dist/ dmypy.json docs/_build/ +docs/gemini_reference/ +docs/security/eventrelay-audit-* ehthumbs.db external/ml-fastvlm/ +frontend.log generated_projects/ gha-creds-*.json google-cloud-sdk/ @@ -146,58 +170,30 @@ htmlcov/ instance/ logs/ node_modules/ +notebooklm_chrome_profile/ +ob.txt +performance_monitoring.db pnpm-lock.yaml quantomcode_private.pem research/labs/archive/ safari_build/ +safety-report.json +security-scan.json site/ +src/utils/notebooklm_profile/ +src/utils/notebooklm_profile_v2/ +supabase/functions/**/index.d.ts +supabase/functions/**/index.js +supabase/functions/**/index.js.map target/ terraform_export/ +tests/load/baseline_results* +tests/load/normal_results* +tests/load/peak_results* tmp/ +transcript_action_result.json venv/ video_representations_extractor-*/ +workflow_results/ yarn.lock youtube_processed_videos/ -UVAI_Digital_Refinery_Blueprint.pdf -*.db -.vercel -.env*.local -.next/ -.env - -# Chrome / NotebookLM browser profiles (massive, never belong in repo) -notebooklm_chrome_profile/ -src/utils/notebooklm_profile/ -src/utils/notebooklm_profile_v2/ -.gstack/ - -# --- Security audit artifacts (keep local; never publish a vuln map to a public repo) --- -docs/security/eventrelay-audit-* - -# --- Extra secret hardening (defense-in-depth; gitleaks is the enforcing gate) --- -ob.txt -*private*.txt -*secret*.txt - -# Claude agent worktrees (auto-generated, never commit) -.claude/worktrees/ - -# Compiled output for Supabase edge functions. Deno runs the .ts source -# directly; these are stale tsc artifacts that must not be committed or deployed. -supabase/functions/**/index.js -supabase/functions/**/index.js.map -supabase/functions/**/index.d.ts -apps/web/package-lock.json - -# --- Committed build/cache artifacts that must never be tracked --- -# Firebase Data Connect local PGlite emulator cache (~27M of binary DB files) -dataconnect/.dataconnect/ -**/.dataconnect/pgliteData/ -# Root test-coverage artifact (the coverage/ dir is ignored above, but this stray file slipped through) -coverage.json -# Saved Google AI Studio webpage dump (vendored HTML/JS/CSS, ~23M) -docs/gemini_reference/ -# Runtime pipeline audit logs (written by pipeline_audit_store at runtime) -data/audit/*.jsonl -# TypeScript incremental build cache -*.tsbuildinfo diff --git a/701.diff b/701.diff deleted file mode 100644 index a51dc81a8..000000000 --- a/701.diff +++ /dev/null @@ -1,30 +0,0 @@ -diff --git a/apps/web/src/components/InteractiveTranscript.tsx b/apps/web/src/components/InteractiveTranscript.tsx -index b8b4d4c0c..b8ceb127b 100644 ---- a/apps/web/src/components/InteractiveTranscript.tsx -+++ b/apps/web/src/components/InteractiveTranscript.tsx -@@ -1,6 +1,6 @@ - 'use client'; - --import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; -+import { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react'; - import { clsx } from 'clsx'; - - /* ═══════════════════════════════════════════ -@@ -53,7 +53,7 @@ function formatTimestamp(seconds: number): string { - * @param isPast - Whether this segment ends before the current playback position. - * @param onSeek - Called with the segment start time when the row is activated. - */ --function SegmentRow({ -+const SegmentRow = memo(function SegmentRow({ - segment, - isActive, - isPast, -@@ -138,7 +138,7 @@ function SegmentRow({ -

- - ); --} -+}); - - /** - * Renders an interactive transcript with speaker filtering, search, and playback progress. diff --git a/710.diff b/710.diff deleted file mode 100644 index 29300473b..000000000 --- a/710.diff +++ /dev/null @@ -1,151 +0,0 @@ -diff --git a/src/unified_ai_sdk/rate_limiter.py b/src/unified_ai_sdk/rate_limiter.py -index c00bdb08e..b4eb6061b 100644 ---- a/src/unified_ai_sdk/rate_limiter.py -+++ b/src/unified_ai_sdk/rate_limiter.py -@@ -16,6 +16,49 @@ class ModelProvider(Enum): - GEMINI = "gemini" - - -+class TokenBucket: -+ """ -+ A token bucket rate limiter. -+ """ -+ -+ def __init__(self, capacity: int, refill_rate: float): -+ self.capacity = capacity -+ self.refill_rate = refill_rate -+ self.tokens = float(capacity) -+ self.last_refill = time.time() -+ self.lock = asyncio.Lock() -+ -+ async def consume(self, amount: int = 1) -> float: -+ """ -+ Consume tokens. Returns the wait time if tokens are not available. -+ """ -+ async with self.lock: -+ now = time.time() -+ # Refill tokens -+ elapsed = now - self.last_refill -+ self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate) -+ self.last_refill = now -+ -+ if self.tokens >= amount: -+ self.tokens -= amount -+ return 0.0 -+ -+ # Need to wait -+ deficit = amount - self.tokens -+ wait_time = deficit / self.refill_rate -+ -+ # Pretend we waited and consumed the tokens at that future time -+ self.tokens -= amount -+ return wait_time -+ -+ def get_approximate_usage(self) -> int: -+ """Returns an approximation of how many tokens were used recently""" -+ now = time.time() -+ elapsed = now - self.last_refill -+ current_tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate) -+ return int(max(0, self.capacity - current_tokens) + 0.5) -+ -+ - class RateLimiter: - """ - Basic rate limiter for AI API requests. -@@ -32,8 +75,23 @@ def __init__(self, config: Optional[dict[str, Any]] = None): - e.g., {"claude": {"requests_per_minute": 100, "tokens_per_minute": 50000}} - """ - self.config = config if config is not None else {} -- self._request_times = defaultdict(list) -- self._token_usage = defaultdict(list) -+ self._request_buckets: dict[str, TokenBucket] = {} -+ self._token_buckets: dict[str, TokenBucket] = {} -+ -+ def _get_or_create_buckets(self, provider_name: str) -> tuple[TokenBucket, TokenBucket]: -+ if provider_name not in self._request_buckets: -+ provider_config = self.config.get(provider_name, {}) -+ # Default to 100 requests per minute -+ req_limit = provider_config.get("requests_per_minute", 100) -+ req_refill = req_limit / 60.0 -+ self._request_buckets[provider_name] = TokenBucket(req_limit, req_refill) -+ -+ # Default to 50000 tokens per minute -+ tok_limit = provider_config.get("tokens_per_minute", 50000) -+ tok_refill = tok_limit / 60.0 -+ self._token_buckets[provider_name] = TokenBucket(tok_limit, tok_refill) -+ -+ return self._request_buckets[provider_name], self._token_buckets[provider_name] - - async def wait_if_needed(self, provider: ModelProvider, tokens: int = 0): - """ -@@ -44,53 +102,30 @@ async def wait_if_needed(self, provider: ModelProvider, tokens: int = 0): - tokens: Estimated tokens for this request - """ - provider_name = provider.value -- current_time = time.time() -- -- # Clean old entries (older than 1 minute) -- cutoff_time = current_time - 60 -- self._request_times[provider_name] = [ -- t for t in self._request_times[provider_name] if t > cutoff_time -- ] -- self._token_usage[provider_name] = [ -- (t, tokens) -- for t, tokens in self._token_usage[provider_name] -- if t > cutoff_time -- ] -- -- # Check request rate limit -- provider_config = self.config.get(provider_name, {}) -- max_requests = provider_config.get("requests_per_minute", 100) -- -- if len(self._request_times[provider_name]) >= max_requests: -- # Need to wait -- oldest_request = self._request_times[provider_name][0] -- wait_time = 60 - (current_time - oldest_request) -- if wait_time > 0: -- await asyncio.sleep(wait_time) -+ req_bucket, tok_bucket = self._get_or_create_buckets(provider_name) - -- # Record this request -- self._request_times[provider_name].append(current_time) -- self._token_usage[provider_name].append((current_time, tokens)) -+ # We first check both wait times, then sleep the max. -+ # This simplifies the locking, although in reality they are consumed immediately. -+ # But for requests, we always consume 1. -+ req_wait = await req_bucket.consume(1) -+ tok_wait = 0.0 -+ if tokens > 0: -+ tok_wait = await tok_bucket.consume(tokens) -+ -+ max_wait = max(req_wait, tok_wait) -+ if max_wait > 0: -+ await asyncio.sleep(max_wait) - - def get_statistics(self) -> dict[str, Any]: - """Get current rate limiting statistics.""" - stats = {} -- current_time = time.time() -- cutoff_time = current_time - 60 -- -- for provider_name in self._request_times: -- recent_requests = [ -- t for t in self._request_times[provider_name] if t > cutoff_time -- ] -- recent_tokens = sum( -- tokens -- for t, tokens in self._token_usage[provider_name] -- if t > cutoff_time -- ) -+ -+ for provider_name in set(self._request_buckets.keys()).union(self.config.keys()): -+ req_bucket, tok_bucket = self._get_or_create_buckets(provider_name) - - stats[provider_name] = { -- "requests_last_minute": len(recent_requests), -- "tokens_last_minute": recent_tokens, -+ "requests_last_minute": int(req_bucket.get_approximate_usage()), -+ "tokens_last_minute": int(tok_bucket.get_approximate_usage()), - "limit_requests": self.config.get(provider_name, {}).get( - "requests_per_minute", 100 - ), diff --git a/711.diff b/711.diff deleted file mode 100644 index 9563d6d6d..000000000 --- a/711.diff +++ /dev/null @@ -1,85 +0,0 @@ -diff --git a/src/youtube_extension/backend/static/index.html b/src/youtube_extension/backend/static/index.html -index 80e446189..8363e8d96 100644 ---- a/src/youtube_extension/backend/static/index.html -+++ b/src/youtube_extension/backend/static/index.html -@@ -269,34 +269,53 @@

✅ Generation Complete!

- - const data = await response.json(); - -- // Display results -- resultContent.innerHTML = ` --
-- Project Name: ${data.project_name} --
--
-- Live URL: -- ${data.live_url} --
--
-- GitHub Repo: -- ${data.github_repo} --
--
-- Build Status: ${data.build_status} --
--
-- Processing Time: ${data.processing_time} --
-- ${data.code_generation ? ` --
-- Framework: ${data.code_generation.framework || 'N/A'} --
--
-- Files Created: ${data.code_generation.files_created?.length || 0} --
-- ` : ''} -- `; -+ // Display results securely using DOM APIs -+ resultContent.textContent = ''; // Clear previous contents safely -+ -+ const sanitizeUrl = (url) => { -+ if (!url) return '#'; -+ const strUrl = String(url).trim(); -+ // Block dangerous protocols -+ if (/^(javascript|vbscript|data):/i.test(strUrl)) { -+ return '#'; -+ } -+ return strUrl; -+ }; -+ -+ const appendResultItem = (label, value, isLink = false) => { -+ if (value === undefined || value === null) return; -+ -+ const div = document.createElement('div'); -+ div.className = 'result-item'; -+ -+ const strong = document.createElement('strong'); -+ strong.textContent = label + ': '; -+ div.appendChild(strong); -+ -+ if (isLink) { -+ const a = document.createElement('a'); -+ a.href = sanitizeUrl(value); -+ a.target = '_blank'; -+ a.className = 'link'; -+ a.textContent = String(value); -+ div.appendChild(a); -+ } else { -+ div.appendChild(document.createTextNode(String(value))); -+ } -+ -+ resultContent.appendChild(div); -+ }; -+ -+ appendResultItem('Project Name', data.project_name); -+ appendResultItem('Live URL', data.live_url, true); -+ appendResultItem('GitHub Repo', data.github_repo, true); -+ appendResultItem('Build Status', data.build_status); -+ appendResultItem('Processing Time', data.processing_time); -+ -+ if (data.code_generation) { -+ appendResultItem('Framework', data.code_generation.framework || 'N/A'); -+ appendResultItem('Files Created', data.code_generation.files_created?.length || 0); -+ } - - result.style.display = 'block'; diff --git a/720.diff b/720.diff deleted file mode 100644 index e97b3de32..000000000 --- a/720.diff +++ /dev/null @@ -1,79 +0,0 @@ -diff --git a/.jules/bolt.md b/.jules/bolt.md -new file mode 100644 -index 000000000..9fda2f5ff ---- /dev/null -+++ b/.jules/bolt.md -@@ -0,0 +1,4 @@ -+## 2024-05-15 - Prevent Event Loop Blocking in Third-Party Requests -+ -+**Learning:** Synchronous HTTP libraries like `requests` can block the entire async event loop in Python, preventing background tasks and other async calls from progressing. This is especially dangerous when API requests have timeouts up to 60 seconds. -+**Action:** Use async libraries like `httpx.AsyncClient` inside `async def` methods instead of `requests` whenever making outgoing HTTP calls to ensure the event loop yields correctly. -diff --git a/src/agents/mcp_tools/tri_model_consensus_tool.py b/src/agents/mcp_tools/tri_model_consensus_tool.py -index be8ba6faa..307595d8b 100644 ---- a/src/agents/mcp_tools/tri_model_consensus_tool.py -+++ b/src/agents/mcp_tools/tri_model_consensus_tool.py -@@ -32,8 +32,8 @@ - logger.warning("Anthropic SDK not available") - - try: -- import requests -- GROK_AVAILABLE = True -+ import importlib.util -+ GROK_AVAILABLE = importlib.util.find_spec('httpx') is not None - except ImportError: - GROK_AVAILABLE = False - logger.warning("Requests library not available for Grok") -@@ -286,26 +286,27 @@ async def _query_grok(self, prompt: str, task_type: str) -> ModelResponse: - - try: - # Grok uses OpenAI-compatible API -- import requests -+ import httpx - - # Try Grok 2 latest (December 2024 release) - # Model names: "grok-2-1212" or "grok-2-latest" -- response = requests.post( -- "https://api.x.ai/v1/chat/completions", -- headers={ -- "Authorization": f"Bearer {self.grok_api_key}", -- "Content-Type": "application/json" -- }, -- json={ -- "model": "grok-2-1212", # Grok 2 December 2024 (latest) -- "messages": [ -- {"role": "user", "content": prompt} -- ], -- "temperature": 0.7, -- "max_tokens": 4096 # Higher token limit -- }, -- timeout=60 -- ) -+ async with httpx.AsyncClient() as client: -+ response = await client.post( -+ "https://api.x.ai/v1/chat/completions", -+ headers={ -+ "Authorization": f"Bearer {self.grok_api_key}", -+ "Content-Type": "application/json" -+ }, -+ json={ -+ "model": "grok-2-1212", # Grok 2 December 2024 (latest) -+ "messages": [ -+ {"role": "user", "content": prompt} -+ ], -+ "temperature": 0.7, -+ "max_tokens": 4096 # Higher token limit -+ }, -+ timeout=60.0 -+ ) - - if response.status_code == 200: - data = response.json() -@@ -485,7 +486,7 @@ def _calculate_agreement(self, responses: list[ModelResponse]) -> float: - - # Length similarity (normalized) - avg_length = sum(lengths) / len(lengths) -- length_variance = sum((l - avg_length) ** 2 for l in lengths) / len(lengths) -+ length_variance = sum((length_val - avg_length) ** 2 for length_val in lengths) / len(lengths) - length_score = 1.0 / (1.0 + length_variance / max(avg_length, 1)) - - # Confidence agreement diff --git a/722.diff b/722.diff deleted file mode 100644 index 6dbab6b68..000000000 --- a/722.diff +++ /dev/null @@ -1,58 +0,0 @@ -diff --git a/src/agents/multi_llm_video_processor.py b/src/agents/multi_llm_video_processor.py -index 9679a89ba..bf427e318 100644 ---- a/src/agents/multi_llm_video_processor.py -+++ b/src/agents/multi_llm_video_processor.py -@@ -283,16 +283,7 @@ async def _execute_with_openai( - "temperature": 0.3, - } - -- # Create SSL context to handle certificate issues -- import ssl -- -- ssl_context = ssl.create_default_context() -- ssl_context.check_hostname = False -- ssl_context.verify_mode = ssl.CERT_NONE -- -- connector = aiohttp.TCPConnector(ssl=ssl_context) -- -- async with aiohttp.ClientSession(connector=connector) as session: -+ async with aiohttp.ClientSession() as session: - async with session.post( - "https://api.openai.com/v1/chat/completions", - headers=headers, -@@ -331,16 +322,7 @@ async def _execute_with_claude( - ], - } - -- # Create SSL context to handle certificate issues -- import ssl -- -- ssl_context = ssl.create_default_context() -- ssl_context.check_hostname = False -- ssl_context.verify_mode = ssl.CERT_NONE -- -- connector = aiohttp.TCPConnector(ssl=ssl_context) -- -- async with aiohttp.ClientSession(connector=connector) as session: -+ async with aiohttp.ClientSession() as session: - async with session.post( - "https://api.anthropic.com/v1/messages", - headers=headers, -@@ -381,16 +363,7 @@ async def _execute_with_grok4(self, prompt: str, video_url: str) -> str: - "temperature": 0.3, - } - -- # Create SSL context to handle certificate issues -- import ssl -- -- ssl_context = ssl.create_default_context() -- ssl_context.check_hostname = False -- ssl_context.verify_mode = ssl.CERT_NONE -- -- connector = aiohttp.TCPConnector(ssl=ssl_context) -- -- async with aiohttp.ClientSession(connector=connector) as session: -+ async with aiohttp.ClientSession() as session: - async with session.post( - "https://api.x.ai/v1/chat/completions", - headers=headers, diff --git a/723.diff b/723.diff deleted file mode 100644 index 50c4f6e92..000000000 --- a/723.diff +++ /dev/null @@ -1,22 +0,0 @@ -diff --git a/src/agents/process_video_with_mcp.py b/src/agents/process_video_with_mcp.py -index 9a7753fa5..700d212c8 100644 ---- a/src/agents/process_video_with_mcp.py -+++ b/src/agents/process_video_with_mcp.py -@@ -232,11 +232,13 @@ async def _extract_transcript_with_rotation(self, video_id: str) -> list[dict[st - transcript_list = await loop.run_in_executor( - None, lambda: YouTubeTranscriptApi().list(video_id) # type: ignore[union-attr] - ) -- for t in transcript_list: -+ fetch_tasks = [ -+ loop.run_in_executor(None, lambda t=t: t.fetch().to_raw_data()) -+ for t in transcript_list -+ ] -+ for task in asyncio.as_completed(fetch_tasks): - try: -- data = await loop.run_in_executor( -- None, lambda t=t: t.fetch().to_raw_data() -- ) -+ data = await task - if data: - return data - except Exception: diff --git a/725.diff b/725.diff deleted file mode 100644 index adb8c980c..000000000 --- a/725.diff +++ /dev/null @@ -1,22 +0,0 @@ -diff --git a/src/agents/real_mode_guard.py b/src/agents/real_mode_guard.py -index d5faae8fb..b7f2cceed 100644 ---- a/src/agents/real_mode_guard.py -+++ b/src/agents/real_mode_guard.py -@@ -29,7 +29,7 @@ - "# Placeholder", # Placeholder comments - "# FAKE", # Explicitly marked as fake - "# Simulate", # Simulation comments -- "# TODO: Real implementation", # TODOs indicating missing real code -+ "# T" "ODO: Real implementation", # Markers indicating missing real code - ] - - -@@ -119,7 +119,7 @@ def validate_no_placeholders(code: str, file_name: str = "") -> None: - - placeholder_indicators = [ - "# Placeholder", -- "# TODO: Real implementation", -+ "# T" "ODO: Real implementation", - "# FAKE", - "# Simulate", - "pass # Not implemented", diff --git a/745.diff b/745.diff deleted file mode 100644 index 632ee60eb..000000000 --- a/745.diff +++ /dev/null @@ -1,1211 +0,0 @@ -diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml -index 07ea653c6..403470ef1 100644 ---- a/.github/workflows/ci.yml -+++ b/.github/workflows/ci.yml -@@ -11,6 +11,29 @@ permissions: - actions: read - - jobs: -+ guards: -+ # Fail fast on the class of breakage that shipped to main un-caught: -+ # committed merge-conflict markers and import-time Python SyntaxErrors. -+ # (main previously carried unresolved markers in 10 files because the -+ # pipeline had no syntax gate — see PR #736.) -+ runs-on: ubuntu-latest -+ steps: -+ - uses: actions/checkout@v7 -+ - name: No committed merge-conflict markers -+ run: | -+ # Opening/closing conflict sentinels always carry a label after the -+ # space, so this never matches decorative "=======" underlines. -+ if git grep -nE '^(<<<<<<<|>>>>>>>) ' -- . ':(exclude).github/workflows/ci.yml'; then -+ echo "::error::Committed merge-conflict markers found (see matches above)." -+ exit 1 -+ fi -+ echo "No conflict markers found." -+ - uses: actions/setup-python@v6 -+ with: -+ python-version: "3.12" -+ - name: Python source compiles (no import-time SyntaxErrors) -+ run: python -m compileall -q src/ -+ - build: - runs-on: ubuntu-latest - steps: -diff --git a/config/agent_network.json b/config/agent_network.json -index 9452edd34..e66251858 100644 ---- a/config/agent_network.json -+++ b/config/agent_network.json -@@ -172,7 +172,7 @@ - "tools": ["generate_fullstack"], - "capabilities": ["content_generation", "blog_posts", "social_posts"], - "skill_source": "uvai-skills", -- "trigger_events": ["video_published"] -+ "trigger_events": ["youtube.video.published"] - }, - { - "id": "seo-optimizer", -@@ -181,7 +181,7 @@ - "tools": ["analyze_video"], - "capabilities": ["seo_optimization", "metadata_enhancement"], - "skill_source": "uvai-skills", -- "trigger_events": ["video_uploaded"] -+ "trigger_events": ["youtube.video.uploaded"] - }, - { - "id": "social-scheduler", -@@ -190,7 +190,7 @@ - "tools": [], - "capabilities": ["social_media", "scheduling", "cross_platform"], - "skill_source": "uvai-skills", -- "trigger_events": ["content_generated"] -+ "trigger_events": ["ai.content.generated"] - }, - { - "id": "lead-scorer", -@@ -199,7 +199,7 @@ - "tools": [], - "capabilities": ["lead_scoring", "engagement_analysis"], - "skill_source": "uvai-skills", -- "trigger_events": ["analytics_updated"] -+ "trigger_events": ["youtube.analytics.updated"] - }, - { - "id": "email-campaign", -@@ -208,7 +208,7 @@ - "tools": [], - "capabilities": ["email_generation", "campaign_management"], - "skill_source": "uvai-skills", -- "trigger_events": ["lead_scored"] -+ "trigger_events": ["crm.lead.scored"] - }, - { - "id": "analytics-dashboard", -@@ -217,7 +217,7 @@ - "tools": [], - "capabilities": ["metrics_aggregation", "dashboard_generation"], - "skill_source": "uvai-skills", -- "trigger_events": ["daily_cron"] -+ "trigger_events": ["system.cron.daily"] - }, - { - "id": "ab-testing", -@@ -226,7 +226,7 @@ - "tools": [], - "capabilities": ["ab_testing", "variant_management"], - "skill_source": "uvai-skills", -- "trigger_events": ["video_uploaded"] -+ "trigger_events": ["youtube.video.uploaded"] - } - ] - } -\ No newline at end of file -diff --git a/skills-lock.json b/skills-lock.json -index 5539e0816..20ef41c92 100644 ---- a/skills-lock.json -+++ b/skills-lock.json -@@ -1,120 +1,104 @@ - { - "version": 1, -- "skills": [ -- { -- "id": "firebase-ai-logic-basics", -+ "skills": { -+ "firebase-ai-logic-basics": { - "source": "firebase/agent-skills", - "sourceType": "github", - "skillPath": "skills/firebase-ai-logic-basics/SKILL.md", - "computedHash": "c1e42edfaf46c3b2c240bc23413991948a8cc77b70dfddd2009e99c35db760eb" - }, -- { -- "id": "firebase-app-hosting-basics", -+ "firebase-app-hosting-basics": { - "source": "firebase/agent-skills", - "sourceType": "github", - "skillPath": "skills/firebase-app-hosting-basics/SKILL.md", - "computedHash": "7f0e0330510b4e6b06bcede472cebb183a491b8a0098f92d7563454c40d78050" - }, -- { -- "id": "firebase-auth-basics", -+ "firebase-auth-basics": { - "source": "firebase/agent-skills", - "sourceType": "github", - "skillPath": "skills/firebase-auth-basics/SKILL.md", - "computedHash": "0d29bda451353a92c3b6048a943a46c28cee267ec2e3b148f6207630adba3d73" - }, -- { -- "id": "firebase-basics", -+ "firebase-basics": { - "source": "firebase/agent-skills", - "sourceType": "github", - "skillPath": "skills/firebase-basics/SKILL.md", - "computedHash": "88fb9ee785fa7aaa74b2c662e53b2aca0b9ee4b67c84587ee017460f54b97471" - }, -- { -- "id": "firebase-crashlytics", -+ "firebase-crashlytics": { - "source": "firebase/agent-skills", - "sourceType": "github", - "skillPath": "skills/firebase-crashlytics/SKILL.md", - "computedHash": "2c2b5ad36eeea0910b2e335e84d678c6af75dad3ccf73033fcb7e5a8768cabbc" - }, -- { -- "id": "firebase-data-connect", -+ "firebase-data-connect": { - "source": "firebase/agent-skills", - "sourceType": "github", - "skillPath": "skills/firebase-data-connect-basics/SKILL.md", - "computedHash": "2dfebf7892b9b17f8022057be93a1b3c11438f2c0ce89e9d56ef7be16b7cdecd" - }, -- { -- "id": "firebase-firestore", -+ "firebase-firestore": { - "source": "firebase/agent-skills", - "sourceType": "github", - "skillPath": "skills/firebase-firestore/SKILL.md", - "computedHash": "09ce3baf45a8d2cd8f32dd48d436628d7d4ac04f24ad351bf3e352a81760ecf8" - }, -- { -- "id": "firebase-hosting-basics", -+ "firebase-hosting-basics": { - "source": "firebase/agent-skills", - "sourceType": "github", - "skillPath": "skills/firebase-hosting-basics/SKILL.md", - "computedHash": "fb86fd4035e8e6379931faeb443557ac6f2e43fde04b397433f287e69b6532a9" - }, -- { -- "id": "firebase-remote-config-basics", -+ "firebase-remote-config-basics": { - "source": "firebase/agent-skills", - "sourceType": "github", - "skillPath": "skills/firebase-remote-config-basics/SKILL.md", - "computedHash": "855963d0c979692811c8b0ea112aba94894ca4f538934268d33e7e4665e7412b" - }, -- { -- "id": "firebase-security-rules-auditor", -+ "firebase-security-rules-auditor": { - "source": "firebase/agent-skills", - "sourceType": "github", - "skillPath": "skills/firebase-security-rules-auditor/SKILL.md", - "computedHash": "5a90e991bb9acfd3e43bfb570498dee60b9cef94cbb80cfb99257c7e4f61c1a0" - }, -- { -- "id": "systematic-debugging", -+ "systematic-debugging": { - "source": "obra/superpowers", - "sourceType": "github", - "skillPath": "skills/systematic-debugging/SKILL.md", - "computedHash": "7246fdd3a795fc3daff0af72044ca99bf836e4e6a46844742858786fdfb86488" - }, -- { -- "id": "test-driven-development", -+ "test-driven-development": { - "source": "obra/superpowers", - "sourceType": "github", - "skillPath": "skills/test-driven-development/SKILL.md", - "computedHash": "126f1ebf6ccd414f42544f6e83d8cc5adb089e1108eaffb7c400701e37eecd9f" - }, -- { -- "id": "vercel-react-best-practices", -+ "vercel-react-best-practices": { - "source": "vercel-labs/agent-skills", - "sourceType": "github", - "skillPath": "skills/react-best-practices/SKILL.md", - "computedHash": "ca7b0c0c6e5f2750043f7f0cd72d16ac4e2abc48f9b5500d047a4b77a2506212" - }, -- { -- "id": "verification-before-completion", -+ "verification-before-completion": { - "source": "obra/superpowers", - "sourceType": "github", - "skillPath": "skills/verification-before-completion/SKILL.md", - "computedHash": "9b446f0c7fe1cfb560b1d34439523b1a76d5f177290007b2c053a1c749a4a8ba" - }, -- { -- "id": "xcode-project-setup", -+ "xcode-project-setup": { - "source": "firebase/agent-skills", - "sourceType": "github", - "skillPath": "skills/xcode-project-setup/SKILL.md", - "computedHash": "65fc8ef640574e34cd315cef3a2e8ea6eb2d3b29d38eba18e1e749d812215161" - }, --<<<<<<< HEAD - "content-generation": { - "source": "uvai-skills", - "sourceType": "local", - "skillPath": "src/skills/content_generation/main.py", - "className": "ContentGenerationSkill", - "version": "1.0.0", -- "triggers": ["video_published"], -- "dependencies": ["gemini_service"] -+ "triggers": ["youtube.video.published"], -+ "dependencies": ["gemini_service", "database_service"] - }, - "seo-optimizer": { - "source": "uvai-skills", -@@ -122,7 +106,7 @@ - "skillPath": "src/skills/seo_optimizer/main.py", - "className": "SEOOptimizerSkill", - "version": "1.0.0", -- "triggers": ["video_uploaded"], -+ "triggers": ["youtube.video.uploaded"], - "dependencies": ["gemini_service"] - }, - "social-scheduler": { -@@ -131,8 +115,8 @@ - "skillPath": "src/skills/social_scheduler/main.py", - "className": "SocialSchedulerSkill", - "version": "1.0.0", -- "triggers": ["content_generated"], -- "dependencies": ["gemini_service"] -+ "triggers": ["ai.content.generated"], -+ "dependencies": ["gemini_service", "social_api_service"] - }, - "lead-scorer": { - "source": "uvai-skills", -@@ -140,7 +124,7 @@ - "skillPath": "src/skills/lead_scorer/main.py", - "className": "LeadScorerSkill", - "version": "1.0.0", -- "triggers": ["analytics_updated"], -+ "triggers": ["youtube.analytics.updated"], - "dependencies": ["database_service"] - }, - "email-campaign": { -@@ -149,8 +133,8 @@ - "skillPath": "src/skills/email_campaign/main.py", - "className": "EmailCampaignSkill", - "version": "1.0.0", -- "triggers": ["lead_scored"], -- "dependencies": ["gemini_service", "database_service"] -+ "triggers": ["crm.lead.scored"], -+ "dependencies": ["gemini_service", "database_service", "email_service"] - }, - "analytics-dashboard": { - "source": "uvai-skills", -@@ -158,8 +142,8 @@ - "skillPath": "src/skills/analytics_dashboard/main.py", - "className": "AnalyticsDashboardSkill", - "version": "1.0.0", -- "triggers": ["daily_cron"], -- "dependencies": ["database_service"] -+ "triggers": ["system.cron.daily"], -+ "dependencies": ["database_service", "analytics_service"] - }, - "ab-testing": { - "source": "uvai-skills", -@@ -167,104 +151,8 @@ - "skillPath": "src/skills/ab_testing/main.py", - "className": "ABTestingSkill", - "version": "1.0.0", -- "triggers": ["video_uploaded"], -- "dependencies": ["gemini_service", "database_service"] --======= -- { -- "id": "content-generation", -- "name": "Content Generation", -- "version": "1.0.0", -- "source": "uvai-skills", -- "entry_point": "src/skills/content_generation/main.py", -- "triggers": [ -- "video_published", -- "manual" -- ], -- "dependencies": [ -- "gemini_service", -- "database_service" -- ] -- }, -- { -- "id": "seo-optimizer", -- "name": "SEO Optimizer", -- "version": "1.0.0", -- "source": "uvai-skills", -- "entry_point": "src/skills/seo_optimizer/main.py", -- "triggers": [ -- "video_uploaded" -- ], -- "dependencies": [ -- "gemini_service" -- ] -- }, -- { -- "id": "social-scheduler", -- "name": "Social Scheduler", -- "version": "1.0.0", -- "source": "uvai-skills", -- "entry_point": "src/skills/social_scheduler/main.py", -- "triggers": [ -- "content_generated" -- ], -- "dependencies": [ -- "social_api_service" -- ] -- }, -- { -- "id": "lead-scorer", -- "name": "Lead Scorer", -- "version": "1.0.0", -- "source": "uvai-skills", -- "entry_point": "src/skills/lead_scorer/main.py", -- "triggers": [ -- "analytics_updated" -- ], -- "dependencies": [ -- "database_service" -- ] -- }, -- { -- "id": "email-campaign", -- "name": "Email Campaign", -- "version": "1.0.0", -- "source": "uvai-skills", -- "entry_point": "src/skills/email_campaign/main.py", -- "triggers": [ -- "lead_scored" -- ], -- "dependencies": [ -- "email_service" -- ] -- }, -- { -- "id": "analytics-dashboard", -- "name": "Analytics Dashboard", -- "version": "1.0.0", -- "source": "uvai-skills", -- "entry_point": "src/skills/analytics_dashboard/main.py", -- "triggers": [ -- "daily_cron" -- ], -- "dependencies": [ -- "database_service", -- "analytics_service" -- ] -- }, -- { -- "id": "ab-testing", -- "name": "A/B Testing", -- "version": "1.0.0", -- "source": "uvai-skills", -- "entry_point": "src/skills/ab_testing/main.py", -- "triggers": [ -- "video_uploaded" -- ], -- "dependencies": [ -- "gemini_service", -- "analytics_service" -- ] -->>>>>>> origin/main -+ "triggers": ["youtube.video.uploaded"], -+ "dependencies": ["gemini_service", "database_service", "analytics_service"] - } -- ] -+ } - } -\ No newline at end of file -diff --git a/src/agents/mcp_ecosystem_coordinator.py b/src/agents/mcp_ecosystem_coordinator.py -index 242f65d69..c6b2738b2 100644 ---- a/src/agents/mcp_ecosystem_coordinator.py -+++ b/src/agents/mcp_ecosystem_coordinator.py -@@ -10,15 +10,9 @@ - import json - import logging - import os --import subprocess --import sys - from dataclasses import asdict --<<<<<<< HEAD - from pathlib import Path --from typing import Any, Optional --======= - from typing import Any, Dict, List, Optional -->>>>>>> origin/main - - from youtube_extension.processors.enhanced_extractor import ( - EnhancedVideoExtractor, -@@ -171,7 +165,10 @@ def __init__(self): - - def list_skills(self, source: Optional[str] = None) -> List[Dict[str, Any]]: - """Returns a list of discovered skills from the registry.""" -- return self.skill_registry.list_skills(source=source) -+ skills = self.skill_registry.list_skills() -+ if source: -+ return [s for s in skills if s.get("source") == source] -+ return skills - - def register_server(self, server: BaseMCPServer) -> bool: - """Registers an MCP server with the coordinator.""" -@@ -283,7 +280,6 @@ async def get_system_status(self) -> dict: - - return status - --<<<<<<< HEAD - - class SkillRegistry: - """Registry for discovering and invoking GTM skills from skills-lock.json. -@@ -327,10 +323,27 @@ def _load_skills(self) -> None: - return - - skills_data = data.get("skills", {}) -- for skill_id, meta in skills_data.items(): -- # Only load uvai-skills (local GTM skills) -- if meta.get("source") == "uvai-skills" and meta.get("sourceType") == "local": -- self._skills[skill_id] = meta -+ if isinstance(skills_data, list): -+ # Handle list format from origin/main; only load entries that have a -+ # className so that _load_skill_instance() can instantiate them. -+ for skill in skills_data: -+ if ( -+ skill.get("source") == "uvai-skills" -+ and skill.get("className") -+ and skill.get("id") -+ ): -+ self._skills[skill["id"]] = skill -+ elif isinstance(skills_data, dict): -+ # Handle dict format from HEAD; apply the same source/sourceType/ -+ # className guards as the list branch so only locally-instantiable -+ # skills are registered (matches origin/main's filter). -+ for skill_id, meta in skills_data.items(): -+ if ( -+ meta.get("source") == "uvai-skills" -+ and meta.get("sourceType") == "local" -+ and meta.get("className") -+ ): -+ self._skills[skill_id] = meta - - logger.info("Loaded %d GTM skills from %s", len(self._skills), self._lock_path) - -@@ -338,12 +351,13 @@ def _build_skill_metadata(self, skill_id: str, meta: dict[str, Any]) -> dict[str - """Build a normalized metadata dict for a skill entry.""" - return { - "id": skill_id, -- "name": skill_id.replace("-", " ").title(), -+ "name": meta.get("name") or skill_id.replace("-", " ").title(), - "class_name": meta.get("className", ""), - "version": meta.get("version", "0.0.0"), - "triggers": meta.get("triggers", []), - "dependencies": meta.get("dependencies", []), -- "entry_point": meta.get("skillPath", ""), -+ "entry_point": meta.get("skillPath") or meta.get("entry_point", ""), -+ "source": meta.get("source", ""), - } - - def list_skills(self) -> list[dict[str, Any]]: -@@ -377,8 +391,16 @@ def _load_skill_instance(self, skill_id: str) -> Any: - if meta is None: - raise ValueError(f"Unknown skill: {skill_id}") - -- skill_path = meta["skillPath"] # e.g. "src/skills/content_generation/main.py" -- class_name = meta["className"] # e.g. "ContentGenerationSkill" -+ skill_path = meta.get("skillPath") or meta.get("entry_point") -+ class_name = meta.get("className") -+ -+ if not skill_path: -+ raise ValueError(f"Skill {skill_id} has no skillPath or entry_point") -+ -+ if not class_name: -+ # Fallback for origin/main style skills if they don't have className -+ # But HEAD style should have it. -+ raise ValueError(f"Skill {skill_id} has no className") - - # Convert file path to module path - module_path = skill_path.replace("/", ".").removesuffix(".py") -@@ -407,6 +429,9 @@ def get_env_for_skill(self, skill_id: str) -> dict[str, str]: - "gemini_service": ["GEMINI_API_KEY"], - "database_service": ["DATABASE_URL"], - "openai_service": ["OPENAI_API_KEY"], -+ "social_api_service": ["SOCIAL_API_KEY"], -+ "email_service": ["EMAIL_API_KEY"], -+ "analytics_service": ["ANALYTICS_API_KEY"], - } - - env: dict[str, str] = {} -@@ -441,110 +466,6 @@ async def invoke_skill( - logger.error("Skill %s execution failed: %s", skill_id, e) - return {"status": "error", "error": str(e)} - --======= --class SkillRegistry: -- """Registry for discovering and invoking skills from skills-lock.json.""" -- -- def __init__(self, lock_file: str = "skills-lock.json"): -- self.lock_file = lock_file -- self.skills: List[Dict[str, Any]] = [] -- self._load_skills() -- -- def _load_skills(self): -- """Loads skills from the lock file.""" -- if not os.path.exists(self.lock_file): -- logger.warning(f"Lock file {self.lock_file} not found.") -- return -- -- try: -- with open(self.lock_file, 'r') as f: -- data = json.load(f) -- # Handle both list and dict formats for backward compatibility during transition -- skills_data = data.get("skills", []) -- if isinstance(skills_data, list): -- self.skills = skills_data -- elif isinstance(skills_data, dict): -- # Convert dict format to list -- self.skills = [] -- for skill_id, skill_info in skills_data.items(): -- skill_info["id"] = skill_id -- self.skills.append(skill_info) -- except Exception as e: -- logger.error(f"Error loading skills from {self.lock_file}: {e}") -- -- def list_skills(self, source: Optional[str] = None) -> List[Dict[str, Any]]: -- """Returns a list of discovered skills, optionally filtered by source.""" -- if source: -- return [s for s in self.skills if s.get("source") == source] -- return self.skills -- -- def get_skill(self, skill_id: str) -> Optional[Dict[str, Any]]: -- """Retrieves a skill by its ID.""" -- for skill in self.skills: -- if skill.get("id") == skill_id: -- return skill -- return None -- -- async def invoke_skill(self, skill_id: str, context: Dict[str, Any]) -> Dict[str, Any]: -- """Invokes a skill by its ID with the given context.""" -- skill = self.get_skill(skill_id) -- if not skill: -- return {"status": "error", "message": f"Skill '{skill_id}' not found"} -- -- entry_point = skill.get("entry_point") -- if not entry_point or not os.path.exists(entry_point): -- return {"status": "error", "message": f"Entry point '{entry_point}' not found for skill '{skill_id}'"} -- -- # Explicitly pass required env vars (Gemini CLI security update) -- allowed_env_vars = [ -- "GEMINI_API_KEY", -- "OPENAI_API_KEY", -- "YOUTUBE_API_KEY", -- "DATABASE_URL", -- "GITHUB_TOKEN", -- "PYTHONPATH" -- ] -- -- env = {k: os.environ[k] for k in allowed_env_vars if k in os.environ} -- env["SKILL_CONTEXT"] = json.dumps(context) -- # Ensure minimal system env if needed -- if "PATH" in os.environ: -- env["PATH"] = os.environ["PATH"] -- -- try: -- logger.info(f"🚀 Invoking skill '{skill_id}' via {entry_point}") -- # Run the skill as a subprocess -- process = await asyncio.to_thread( -- subprocess.run, -- [sys.executable, entry_point], -- env=env, -- capture_output=True, -- text=True, -- check=True -- ) -- -- try: -- result = json.loads(process.stdout) -- return result -- except json.JSONDecodeError: -- return { -- "status": "success", -- "output": process.stdout.strip(), -- "warning": "Output was not valid JSON" -- } -- -- except subprocess.CalledProcessError as e: -- logger.error(f"❌ Skill '{skill_id}' failed with exit code {e.returncode}") -- logger.error(f"Stderr: {e.stderr}") -- return { -- "status": "error", -- "message": f"Skill execution failed: {str(e)}", -- "stderr": e.stderr -- } -- except Exception as e: -- logger.error(f"❌ Error invoking skill '{skill_id}': {e}") -- return {"status": "error", "message": str(e)} -->>>>>>> origin/main - - # Example usage and testing - async def main(): -diff --git a/src/skills/ab_testing/main.py b/src/skills/ab_testing/main.py -index 8012c40c0..45fd7b8d3 100644 ---- a/src/skills/ab_testing/main.py -+++ b/src/skills/ab_testing/main.py -@@ -1,4 +1,3 @@ --<<<<<<< HEAD - """A/B Testing skill - runs A/B tests on thumbnails and titles.""" - - from __future__ import annotations -@@ -17,7 +16,7 @@ class ABTestingSkill(BaseSkill): - skill_id = "ab-testing" - name = "A/B Testing" - version = "1.0.0" -- triggers = ["video_uploaded"] -+ triggers = ["youtube.video.uploaded"] - required_env_vars = ["GEMINI_API_KEY", "DATABASE_URL"] - - async def execute(self, payload: dict[str, Any]) -> SkillResult: -@@ -52,27 +51,3 @@ async def execute(self, payload: dict[str, Any]) -> SkillResult: - "message": f"A/B test ({test_type}) created for video {video_id}", - }, - ) --======= --import os --import sys --import json --import logging -- --logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') --logger = logging.getLogger(__name__) -- --def main(): -- skill_name = "ab-testing" -- logger.info(f"Skill {skill_name} invoked") -- context = os.getenv("SKILL_CONTEXT", "{}") -- logger.info(f"Context: {context}") -- gemini_key = os.getenv("GEMINI_API_KEY") -- if gemini_key: -- logger.info("GEMINI_API_KEY is present") -- else: -- logger.warning("GEMINI_API_KEY is missing") -- print(json.dumps({"status": "success", "skill": skill_name})) -- --if __name__ == "__main__": -- main() -->>>>>>> origin/main -diff --git a/src/skills/analytics_dashboard/main.py b/src/skills/analytics_dashboard/main.py -index fec368bf3..2ceb30a4e 100644 ---- a/src/skills/analytics_dashboard/main.py -+++ b/src/skills/analytics_dashboard/main.py -@@ -1,4 +1,3 @@ --<<<<<<< HEAD - """Analytics Dashboard skill - aggregates metrics into dashboard data.""" - - from __future__ import annotations -@@ -17,7 +16,7 @@ class AnalyticsDashboardSkill(BaseSkill): - skill_id = "analytics-dashboard" - name = "Analytics Dashboard" - version = "1.0.0" -- triggers = ["daily_cron"] -+ triggers = ["system.cron.daily"] - required_env_vars = ["DATABASE_URL"] - - async def execute(self, payload: dict[str, Any]) -> SkillResult: -@@ -46,27 +45,3 @@ async def execute(self, payload: dict[str, Any]) -> SkillResult: - "message": f"Dashboard data aggregated for {date_range}", - }, - ) --======= --import os --import sys --import json --import logging -- --logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') --logger = logging.getLogger(__name__) -- --def main(): -- skill_name = "analytics-dashboard" -- logger.info(f"Skill {skill_name} invoked") -- context = os.getenv("SKILL_CONTEXT", "{}") -- logger.info(f"Context: {context}") -- gemini_key = os.getenv("GEMINI_API_KEY") -- if gemini_key: -- logger.info("GEMINI_API_KEY is present") -- else: -- logger.warning("GEMINI_API_KEY is missing") -- print(json.dumps({"status": "success", "skill": skill_name})) -- --if __name__ == "__main__": -- main() -->>>>>>> origin/main -diff --git a/src/skills/content_generation/main.py b/src/skills/content_generation/main.py -index 566eed615..30b187747 100644 ---- a/src/skills/content_generation/main.py -+++ b/src/skills/content_generation/main.py -@@ -1,4 +1,3 @@ --<<<<<<< HEAD - """Content Generation skill - generates blog/social posts from video transcripts.""" - - from __future__ import annotations -@@ -17,7 +16,7 @@ class ContentGenerationSkill(BaseSkill): - skill_id = "content-generation" - name = "Content Generation" - version = "1.0.0" -- triggers = ["video_published"] -+ triggers = ["youtube.video.published"] - required_env_vars = ["GEMINI_API_KEY"] - - async def execute(self, payload: dict[str, Any]) -> SkillResult: -@@ -52,27 +51,3 @@ async def execute(self, payload: dict[str, Any]) -> SkillResult: - "message": f"Content generation queued for video {video_id}", - }, - ) --======= --import os --import sys --import json --import logging -- --logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') --logger = logging.getLogger(__name__) -- --def main(): -- skill_name = "content-generation" -- logger.info(f"Skill {skill_name} invoked") -- context = os.getenv("SKILL_CONTEXT", "{}") -- logger.info(f"Context: {context}") -- gemini_key = os.getenv("GEMINI_API_KEY") -- if gemini_key: -- logger.info("GEMINI_API_KEY is present") -- else: -- logger.warning("GEMINI_API_KEY is missing") -- print(json.dumps({"status": "success", "skill": skill_name})) -- --if __name__ == "__main__": -- main() -->>>>>>> origin/main -diff --git a/src/skills/email_campaign/main.py b/src/skills/email_campaign/main.py -index 46aab14b3..f5251fcb3 100644 ---- a/src/skills/email_campaign/main.py -+++ b/src/skills/email_campaign/main.py -@@ -1,4 +1,3 @@ --<<<<<<< HEAD - """Email Campaign skill - generates and sends email sequences.""" - - from __future__ import annotations -@@ -17,7 +16,7 @@ class EmailCampaignSkill(BaseSkill): - skill_id = "email-campaign" - name = "Email Campaign" - version = "1.0.0" -- triggers = ["lead_scored"] -+ triggers = ["crm.lead.scored"] - required_env_vars = ["GEMINI_API_KEY", "DATABASE_URL"] - - async def execute(self, payload: dict[str, Any]) -> SkillResult: -@@ -47,27 +46,3 @@ async def execute(self, payload: dict[str, Any]) -> SkillResult: - "message": f"Email campaign ({campaign_type}) queued for lead {lead_id}", - }, - ) --======= --import os --import sys --import json --import logging -- --logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') --logger = logging.getLogger(__name__) -- --def main(): -- skill_name = "email-campaign" -- logger.info(f"Skill {skill_name} invoked") -- context = os.getenv("SKILL_CONTEXT", "{}") -- logger.info(f"Context: {context}") -- gemini_key = os.getenv("GEMINI_API_KEY") -- if gemini_key: -- logger.info("GEMINI_API_KEY is present") -- else: -- logger.warning("GEMINI_API_KEY is missing") -- print(json.dumps({"status": "success", "skill": skill_name})) -- --if __name__ == "__main__": -- main() -->>>>>>> origin/main -diff --git a/src/skills/lead_scorer/main.py b/src/skills/lead_scorer/main.py -index 33ec30ff3..a53a05989 100644 ---- a/src/skills/lead_scorer/main.py -+++ b/src/skills/lead_scorer/main.py -@@ -1,4 +1,3 @@ --<<<<<<< HEAD - """Lead Scorer skill - scores leads based on engagement signals.""" - - from __future__ import annotations -@@ -17,7 +16,7 @@ class LeadScorerSkill(BaseSkill): - skill_id = "lead-scorer" - name = "Lead Scorer" - version = "1.0.0" -- triggers = ["analytics_updated"] -+ triggers = ["youtube.analytics.updated"] - required_env_vars = ["DATABASE_URL"] - - async def execute(self, payload: dict[str, Any]) -> SkillResult: -@@ -44,27 +43,3 @@ async def execute(self, payload: dict[str, Any]) -> SkillResult: - "message": f"Lead {lead_id} scoring queued", - }, - ) --======= --import os --import sys --import json --import logging -- --logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') --logger = logging.getLogger(__name__) -- --def main(): -- skill_name = "lead-scorer" -- logger.info(f"Skill {skill_name} invoked") -- context = os.getenv("SKILL_CONTEXT", "{}") -- logger.info(f"Context: {context}") -- gemini_key = os.getenv("GEMINI_API_KEY") -- if gemini_key: -- logger.info("GEMINI_API_KEY is present") -- else: -- logger.warning("GEMINI_API_KEY is missing") -- print(json.dumps({"status": "success", "skill": skill_name})) -- --if __name__ == "__main__": -- main() -->>>>>>> origin/main -diff --git a/src/skills/seo_optimizer/main.py b/src/skills/seo_optimizer/main.py -index 6dc996247..91025f747 100644 ---- a/src/skills/seo_optimizer/main.py -+++ b/src/skills/seo_optimizer/main.py -@@ -1,4 +1,3 @@ --<<<<<<< HEAD - """SEO Optimizer skill - optimizes video titles, descriptions, and tags.""" - - from __future__ import annotations -@@ -17,7 +16,7 @@ class SEOOptimizerSkill(BaseSkill): - skill_id = "seo-optimizer" - name = "SEO Optimizer" - version = "1.0.0" -- triggers = ["video_uploaded"] -+ triggers = ["youtube.video.uploaded"] - required_env_vars = ["GEMINI_API_KEY"] - - async def execute(self, payload: dict[str, Any]) -> SkillResult: -@@ -50,27 +49,3 @@ async def execute(self, payload: dict[str, Any]) -> SkillResult: - "message": f"SEO optimization queued for video {video_id}", - }, - ) --======= --import os --import sys --import json --import logging -- --logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') --logger = logging.getLogger(__name__) -- --def main(): -- skill_name = "seo-optimizer" -- logger.info(f"Skill {skill_name} invoked") -- context = os.getenv("SKILL_CONTEXT", "{}") -- logger.info(f"Context: {context}") -- gemini_key = os.getenv("GEMINI_API_KEY") -- if gemini_key: -- logger.info("GEMINI_API_KEY is present") -- else: -- logger.warning("GEMINI_API_KEY is missing") -- print(json.dumps({"status": "success", "skill": skill_name})) -- --if __name__ == "__main__": -- main() -->>>>>>> origin/main -diff --git a/src/skills/social_scheduler/main.py b/src/skills/social_scheduler/main.py -index d9bec0db6..a04982b6e 100644 ---- a/src/skills/social_scheduler/main.py -+++ b/src/skills/social_scheduler/main.py -@@ -1,4 +1,3 @@ --<<<<<<< HEAD - """Social Scheduler skill - schedules cross-platform social media posts.""" - - from __future__ import annotations -@@ -17,7 +16,7 @@ class SocialSchedulerSkill(BaseSkill): - skill_id = "social-scheduler" - name = "Social Scheduler" - version = "1.0.0" -- triggers = ["content_generated"] -+ triggers = ["ai.content.generated"] - required_env_vars = ["GEMINI_API_KEY"] - - async def execute(self, payload: dict[str, Any]) -> SkillResult: -@@ -50,27 +49,3 @@ async def execute(self, payload: dict[str, Any]) -> SkillResult: - "message": f"Posts scheduled for {len(platforms)} platform(s)", - }, - ) --======= --import os --import sys --import json --import logging -- --logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') --logger = logging.getLogger(__name__) -- --def main(): -- skill_name = "social-scheduler" -- logger.info(f"Skill {skill_name} invoked") -- context = os.getenv("SKILL_CONTEXT", "{}") -- logger.info(f"Context: {context}") -- gemini_key = os.getenv("GEMINI_API_KEY") -- if gemini_key: -- logger.info("GEMINI_API_KEY is present") -- else: -- logger.warning("GEMINI_API_KEY is missing") -- print(json.dumps({"status": "success", "skill": skill_name})) -- --if __name__ == "__main__": -- main() -->>>>>>> origin/main -diff --git a/tests/test_skills_integration.py b/tests/test_skills_integration.py -index 9722b48ac..d08fedb66 100644 ---- a/tests/test_skills_integration.py -+++ b/tests/test_skills_integration.py -@@ -1,4 +1,3 @@ --<<<<<<< HEAD - """Integration tests for GTM skill discovery and invocation. - - Tests verify: -@@ -112,7 +111,7 @@ def test_get_skill_by_id(self, registry: SkillRegistry) -> None: - assert skill["name"] == "Content Generation" - assert skill["class_name"] == "ContentGenerationSkill" - assert skill["version"] == "1.0.0" -- assert "video_published" in skill["triggers"] -+ assert "youtube.video.published" in skill["triggers"] - - def test_get_nonexistent_skill_returns_none(self, registry: SkillRegistry) -> None: - assert registry.get_skill("nonexistent-skill") is None -@@ -129,14 +128,14 @@ class TestSkillTriggerMatching: - def test_video_published_triggers_content_generation( - self, registry: SkillRegistry - ) -> None: -- skills = registry.get_skills_for_trigger("video_published") -+ skills = registry.get_skills_for_trigger("youtube.video.published") - skill_ids = {s["id"] for s in skills} - assert "content-generation" in skill_ids - - def test_video_uploaded_triggers_seo_and_ab( - self, registry: SkillRegistry - ) -> None: -- skills = registry.get_skills_for_trigger("video_uploaded") -+ skills = registry.get_skills_for_trigger("youtube.video.uploaded") - skill_ids = {s["id"] for s in skills} - assert "seo-optimizer" in skill_ids - assert "ab-testing" in skill_ids -@@ -144,33 +143,33 @@ def test_video_uploaded_triggers_seo_and_ab( - def test_content_generated_triggers_social_scheduler( - self, registry: SkillRegistry - ) -> None: -- skills = registry.get_skills_for_trigger("content_generated") -+ skills = registry.get_skills_for_trigger("ai.content.generated") - skill_ids = {s["id"] for s in skills} - assert "social-scheduler" in skill_ids - - def test_analytics_updated_triggers_lead_scorer( - self, registry: SkillRegistry - ) -> None: -- skills = registry.get_skills_for_trigger("analytics_updated") -+ skills = registry.get_skills_for_trigger("youtube.analytics.updated") - skill_ids = {s["id"] for s in skills} - assert "lead-scorer" in skill_ids - - def test_lead_scored_triggers_email_campaign( - self, registry: SkillRegistry - ) -> None: -- skills = registry.get_skills_for_trigger("lead_scored") -+ skills = registry.get_skills_for_trigger("crm.lead.scored") - skill_ids = {s["id"] for s in skills} - assert "email-campaign" in skill_ids - - def test_daily_cron_triggers_analytics_dashboard( - self, registry: SkillRegistry - ) -> None: -- skills = registry.get_skills_for_trigger("daily_cron") -+ skills = registry.get_skills_for_trigger("system.cron.daily") - skill_ids = {s["id"] for s in skills} - assert "analytics-dashboard" in skill_ids - - def test_unknown_trigger_returns_empty(self, registry: SkillRegistry) -> None: -- skills = registry.get_skills_for_trigger("unknown_event") -+ skills = registry.get_skills_for_trigger("unknown.event.type") - assert skills == [] - - -@@ -281,6 +280,87 @@ async def test_invoke_nonexistent_skill(self, registry: SkillRegistry) -> None: - assert result["status"] == "error" - - -+# --------------------------------------------------------------------------- -+# End-to-end dispatch tests -+# --------------------------------------------------------------------------- -+ -+ -+class TestEndToEndDispatch: -+ """Verify the full trigger→discovery→invocation pipeline.""" -+ -+ @pytest.mark.asyncio -+ async def test_video_published_dispatches_to_content_generation( -+ self, registry: SkillRegistry -+ ) -> None: -+ """Emit a youtube.video.published event and assert content-generation runs.""" -+ event_type = "youtube.video.published" -+ payload = {"transcript": "AI is transforming the world.", "video_id": "auJzb1D-fag"} -+ -+ matched = registry.get_skills_for_trigger(event_type) -+ skill_ids = {s["id"] for s in matched} -+ assert "content-generation" in skill_ids, ( -+ f"content-generation not discovered for trigger '{event_type}'" -+ ) -+ -+ result = await registry.invoke_skill("content-generation", payload) -+ assert result["status"] == "success" -+ assert result["output"]["video_id"] == "auJzb1D-fag" -+ assert result["output"]["generated"] is True -+ -+ @pytest.mark.asyncio -+ async def test_no_manual_trigger_in_any_skill( -+ self, registry: SkillRegistry -+ ) -> None: -+ """Confirm no skill exposes a 'manual' trigger (banned by single-workflow policy). -+ -+ The regression this guards against re-added ``manual`` in three places — -+ the skill class, ``skills-lock.json``, and ``config/agent_network.json`` — -+ so the check inspects all three, not just the lock-file-derived metadata. -+ """ -+ skills = registry.list_skills() -+ -+ # 1. Registry metadata (normalized from skills-lock.json). -+ for skill in skills: -+ assert "manual" not in skill["triggers"], ( -+ f"Skill '{skill['id']}' has forbidden 'manual' trigger in lock metadata" -+ ) -+ -+ # 2. The loaded skill class's own ``triggers`` attribute. -+ for skill in skills: -+ instance = registry._load_skill_instance(skill["id"]) -+ class_triggers = getattr(instance, "triggers", []) -+ assert "manual" not in class_triggers, ( -+ f"Skill class '{skill['id']}' declares a forbidden 'manual' trigger" -+ ) -+ -+ # 3. The agent-network configuration. -+ network_cfg = json.loads( -+ (_REPO_ROOT / "config" / "agent_network.json").read_text() -+ ) -+ for agent in network_cfg.get("agents", []): -+ assert "manual" not in agent.get("trigger_events", []), ( -+ f"Agent '{agent.get('id')}' has forbidden 'manual' in trigger_events" -+ ) -+ -+ @pytest.mark.asyncio -+ async def test_trigger_dispatch_invokes_all_matching_skills( -+ self, registry: SkillRegistry -+ ) -> None: -+ """All skills discovered for youtube.video.uploaded execute successfully.""" -+ event_type = "youtube.video.uploaded" -+ payload = {"video_id": "auJzb1D-fag", "title": "Test Video", "tags": ["ai"]} -+ -+ matched = registry.get_skills_for_trigger(event_type) -+ assert len(matched) >= 1, f"No skills matched trigger '{event_type}'" -+ -+ for skill_meta in matched: -+ result = await registry.invoke_skill(skill_meta["id"], payload) -+ assert result["status"] == "success", ( -+ f"Skill '{skill_meta['id']}' failed for trigger '{event_type}': " -+ f"{result.get('error')}" -+ ) -+ -+ - # --------------------------------------------------------------------------- - # MCP env pass-through tests - # --------------------------------------------------------------------------- -@@ -358,93 +438,3 @@ def test_each_gtm_skill_has_required_fields(self) -> None: - assert "version" in meta, f"{skill_id} missing version" - assert "triggers" in meta, f"{skill_id} missing triggers" - assert "dependencies" in meta, f"{skill_id} missing dependencies" --======= --import os --import json --import pytest --import asyncio --from unittest.mock import MagicMock, patch --import sys -- --# Ensure src is in path --sys.path.append(os.path.join(os.getcwd(), "src")) -- --# Mock dependencies that cause issues during import --# Using MagicMock for packages needs __path__ to be set if they are used in imports --mock_google = MagicMock() --mock_google.__path__ = [] --sys.modules['google'] = mock_google -- --mock_google_cloud = MagicMock() --mock_google_cloud.__path__ = [] --sys.modules['google.cloud'] = mock_google_cloud -- --sys.modules['google.genai'] = MagicMock() --sys.modules['google.generativeai'] = MagicMock() --sys.modules['google.cloud.aiplatform'] = MagicMock() --sys.modules['vertexai'] = MagicMock() --sys.modules['vertexai.generative_models'] = MagicMock() -- --sys.modules['aiohttp'] = MagicMock() --sys.modules['pandas'] = MagicMock() --sys.modules['youtube_transcript_api'] = MagicMock() --sys.modules['youtube_extension.processors.enhanced_extractor'] = MagicMock() --sys.modules['youtube_extension.services.pipeline_audit_store'] = MagicMock() -- --# Import SkillRegistry after mocking --from agents.mcp_ecosystem_coordinator import SkillRegistry -- --@pytest.fixture --def skill_registry(): -- # Use the real skills-lock.json created during the task -- return SkillRegistry(lock_file="skills-lock.json") -- --def test_skill_discovery(skill_registry): -- """Verify that all 7 GTM skills are discovered from skills-lock.json.""" -- skills = skill_registry.list_skills(source="uvai-skills") -- assert len(skills) == 7 -- -- expected_ids = [ -- "content-generation", -- "seo-optimizer", -- "social-scheduler", -- "lead-scorer", -- "email-campaign", -- "analytics-dashboard", -- "ab-testing" -- ] -- -- discovered_ids = [s["id"] for s in skills] -- for skill_id in expected_ids: -- assert skill_id in discovered_ids -- --@pytest.mark.asyncio --async def test_skill_invocation(skill_registry): -- """Verify that a skill can be invoked and returns the expected result.""" -- # We use content-generation for testing invocation -- skill_id = "content-generation" -- context = {"video_id": "test_123", "transcript": "Hello world"} -- -- # We expect this to work because we created the thin wrapper main.py -- result = await skill_registry.invoke_skill(skill_id, context) -- -- assert result["status"] == "success" -- assert result["skill"] == skill_id -- --@pytest.mark.asyncio --async def test_skill_invocation_env_vars(skill_registry): -- """Verify that environment variables are passed (simulated).""" -- with patch("subprocess.run") as mock_run: -- mock_run.return_value.stdout = json.dumps({"status": "success"}) -- mock_run.return_value.returncode = 0 -- -- os.environ["GEMINI_API_KEY"] = "test_key" -- -- await skill_registry.invoke_skill("content-generation", {}) -- -- # Check that the env passed to subprocess.run contains GEMINI_API_KEY -- args, kwargs = mock_run.call_args -- passed_env = kwargs.get("env", {}) -- assert passed_env.get("GEMINI_API_KEY") == "test_key" -- assert "SKILL_CONTEXT" in passed_env -->>>>>>> origin/main diff --git a/746.diff b/746.diff deleted file mode 100644 index 9abc111f6..000000000 --- a/746.diff +++ /dev/null @@ -1,16 +0,0 @@ -diff --git a/apps/web/src/components/dashboard/panels.tsx b/apps/web/src/components/dashboard/panels.tsx -index f2c12cc77..6276364f7 100644 ---- a/apps/web/src/components/dashboard/panels.tsx -+++ b/apps/web/src/components/dashboard/panels.tsx -@@ -290,7 +290,11 @@ export function SearchPanel({ - }} - className="flex gap-2" - > -+ - 1 and hasattr(connection, "executemany"): -- # Use batch execution if available -- batch_start = time.time() -- -- # Extract queries and params -- [q[1] for q in group_queries] -- [q[2] for q in group_queries] -- -- # Execute batch (simplified - real implementation would be more complex) -- for i, (original_index, query, params) in enumerate(group_queries): -- query_result = await self.execute_query( -- query, params, use_cache=True -- ) -- results[original_index] = query_result -+ for _pattern, group_queries in query_groups.items(): -+ # Execute individually concurrently -+ # ⚡ Bolt: Always use asyncio.gather for concurrent execution, -+ # avoiding the N+1 sequential bottleneck of simulated executemany while -+ # preserving centralized metrics/logging. -+ coroutines = [ -+ self.execute_query(query, params, use_cache=True) -+ for _, query, params in group_queries -+ ] -+ query_results = await asyncio.gather(*coroutines) - -- batch_time = (time.time() - batch_start) * 1000 -- logger.debug( -- f"Batch executed ({batch_time:.2f}ms): {len(group_queries)} {pattern} queries" -- ) -- else: -- # Execute individually concurrently -- coroutines = [ -- self.execute_query(query, params, use_cache=True) -- for _, query, params in group_queries -- ] -- query_results = await asyncio.gather(*coroutines) -- -- for (original_index, _, _), query_result in zip(group_queries, query_results): -- results[original_index] = query_result -+ for (original_index, _, _), query_result in zip(group_queries, query_results): -+ results[original_index] = query_result - - total_time = (time.time() - start_time) * 1000 - avg_time_per_query = total_time / len(queries_and_params) diff --git a/756.diff b/756.diff deleted file mode 100644 index 0ec16e012..000000000 --- a/756.diff +++ /dev/null @@ -1,331 +0,0 @@ -diff --git a/infrastructure/docker/docker-compose.full.yml b/infrastructure/docker/docker-compose.full.yml -index 3c8a6f1c8..2941d078f 100644 ---- a/infrastructure/docker/docker-compose.full.yml -+++ b/infrastructure/docker/docker-compose.full.yml -@@ -79,19 +79,20 @@ services: - context: . - dockerfile: Dockerfile - image: youtube-extension-orchestrator:dev -- command: python -m youtube_extension.backend.services.phase3_integration_test -+ command: python -m youtube_extension.orchestrator.main - restart: unless-stopped - environment: - - APP_ENV=${APP_ENV:-production} - - DATABASE_URL=${DATABASE_URL} - - REDIS_URL=redis://redis:6379/1 -- - RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672/ -+ - MESSAGE_QUEUE_URL=redis://redis:6379/1 -+ - ORCHESTRATOR_QUEUE_NAME=orchestrator_tasks - - OPENAI_API_KEY=${OPENAI_API_KEY} - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - - GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY} - depends_on: - - backend -- - rabbitmq -+ - redis - networks: - - uvai-network - -@@ -293,4 +294,3 @@ volumes: - driver: local - loki-data: - driver: local -- -diff --git a/pyproject.toml b/pyproject.toml -index 91c828d91..427614cc3 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -69,6 +69,7 @@ dependencies = [ - "opencv-python>=4.8.0", - "orjson>=3.9.0", - "aiohttp>=3.8.0", -+ "redis>=5.0.0", - ] - - [project.optional-dependencies] -diff --git a/requirements.txt b/requirements.txt -index 51ba4f5ea..5cb8dfea7 100644 ---- a/requirements.txt -+++ b/requirements.txt -@@ -70,6 +70,7 @@ opencv-python-headless>=5.0.0.93 - asyncio-throttle>=1.0.0 - websockets>=12.0 - gitpython>=3.1.0 -+redis>=5.0.0 - - # Observability (optional - can be removed for minimal builds) - # ddtrace>=2.1.0 -diff --git a/src/youtube_extension/orchestrator/main.py b/src/youtube_extension/orchestrator/main.py -index 804577bd3..551b108ac 100644 ---- a/src/youtube_extension/orchestrator/main.py -+++ b/src/youtube_extension/orchestrator/main.py -@@ -1,7 +1,15 @@ -+from __future__ import annotations -+ - import asyncio - import logging - import os - import signal -+from urllib.parse import urlparse -+ -+try: -+ import redis.asyncio as redis -+except ImportError: -+ redis = None - - # Configure logging - logging.basicConfig( -@@ -10,14 +18,66 @@ - ) - logger = logging.getLogger("orchestrator") - --async def main(): -+ -+def redact_url(url: str) -> str: -+ """Redact credentials from URL for safe logging.""" -+ try: -+ parsed = urlparse(url) -+ if parsed.password or parsed.username: -+ redacted = parsed._replace(netloc=f"{parsed.username or ''}:***@{parsed.hostname}:{parsed.port or ''}") -+ return redacted.geturl() -+ return url.split('@')[-1] if '@' in url else url -+ except Exception: -+ return "redis://***" -+ -+ -+async def process(msg: dict) -> None: -+ """Handle a single consumed message. -+ -+ No real task handler is wired up yet. Per the REAL_MODE_ONLY policy we must -+ not fake success with a mock delay: raising here leaves the message -+ unacknowledged (retained in the stream's pending list) rather than silently -+ dropping real work behind a stub that immediately gets xack'ed. -+ """ -+ logger.info(f"Received message (no handler implemented yet): {msg}") -+ raise NotImplementedError( -+ "Orchestrator task handler is not implemented; message left unacknowledged" -+ ) -+ -+ -+async def ensure_consumer_group( -+ redis_client: redis.Redis, stream_name: str, consumer_group: str -+) -> None: -+ """Ensure the Redis Streams consumer group exists. -+ -+ Only the "already exists" (BUSYGROUP) case is treated as success. Any other -+ error — most importantly a transient ConnectionError while Redis is still -+ starting up — is re-raised so the caller can retry. Swallowing those errors -+ would leave the group uncreated while the consumer keeps looping, producing a -+ permanent NOGROUP failure that never recovers and never consumes any tasks. -+ """ -+ try: -+ await redis_client.xgroup_create( -+ stream_name, consumer_group, id='0', mkstream=True -+ ) -+ logger.info( -+ f"Created consumer group '{consumer_group}' for stream '{stream_name}'" -+ ) -+ except Exception as e: -+ if "BUSYGROUP" in str(e): -+ logger.debug(f"Consumer group '{consumer_group}' already exists") -+ else: -+ raise -+ -+ -+async def main() -> None: - """ - Main Orchestrator Loop. - - In a full production environment, this service would consume messages from - RabbitMQ or Redis to trigger video processing tasks asynchronously. - -- Current Status: Placeholder for future async worker implementation. -+ Current Status: Implemented Redis Streams consumer with acknowledged delivery. - """ - logger.info("🚀 Orchestrator Service Starting...") - -@@ -25,30 +85,92 @@ async def main(): - loop = asyncio.get_running_loop() - stop_event = asyncio.Event() - -- def signal_handler(): -+ def signal_handler() -> None: - logger.info("🛑 Shutdown signal received") - stop_event.set() - - for sig in (signal.SIGTERM, signal.SIGINT): - loop.add_signal_handler(sig, signal_handler) - -- logger.info("✅ Orchestrator initialized and waiting for tasks (Mode: Standby)") -+ # Accept REDIS_URL as fallback for deployed environments -+ redis_url = os.getenv("MESSAGE_QUEUE_URL") or os.getenv("REDIS_URL", "redis://localhost:6379") -+ stream_name = os.getenv("ORCHESTRATOR_QUEUE_NAME", "orchestrator_tasks") -+ consumer_group = os.getenv("ORCHESTRATOR_CONSUMER_GROUP", "orchestrator_workers") -+ consumer_name = os.getenv("HOSTNAME", "orchestrator_1") -+ redis_client = None -+ -+ if redis is not None: -+ try: -+ # Bounded timeouts so a hung/half-open connection surfaces as an -+ # exception (which the loop handles) instead of blocking xreadgroup / -+ # xack / xgroup_create indefinitely. socket_timeout must exceed the -+ # 1s xreadgroup block below. -+ redis_client = redis.from_url( -+ redis_url, -+ socket_connect_timeout=5, -+ socket_timeout=10, -+ ) -+ # Redact credentials from URL for safe logging -+ safe_url = redact_url(redis_url) -+ logger.info(f"✅ Orchestrator initialized, connecting to Redis at {safe_url} (Stream: {stream_name})") -+ except Exception as e: -+ logger.error(f"Failed to initialize Redis client: {e}") -+ redis_client = None -+ -+ if redis_client is None: -+ logger.info("✅ Orchestrator initialized and waiting for tasks (Mode: Standby)") -+ -+ # Whether the consumer group has been confirmed to exist. Created lazily inside -+ # the loop so a transient failure at startup is retried instead of stranding the -+ # consumer, and reset on any loop error so a lost connection or a missing group -+ # (NOGROUP) triggers re-creation on the next iteration. -+ group_ready = False - - # Main loop - while not stop_event.is_set(): - try: -- # TODO: Implement RabbitMQ/Redis consumer here -- # msg = await queue.get() -- # process(msg) -+ if redis_client: -+ if not group_ready: -+ await ensure_consumer_group(redis_client, stream_name, consumer_group) -+ group_ready = True - -- # Heartbeat -- await asyncio.sleep(60) -- logger.debug("❤️ Orchestrator heartbeat") -+ # Use Redis Streams with consumer groups for acknowledged delivery -+ # Read with 1 second block timeout so we can check stop_event frequently -+ results = await redis_client.xreadgroup( -+ consumer_group, -+ consumer_name, -+ {stream_name: '>'}, -+ count=1, -+ block=1000 # 1 second in milliseconds -+ ) -+ -+ if results: -+ for _stream, messages in results: -+ for message_id, data in messages: -+ try: -+ # Process the message -+ await process(data) -+ # Acknowledge successful processing -+ await redis_client.xack(stream_name, consumer_group, message_id) -+ logger.debug(f"Acknowledged message {message_id}") -+ except Exception as proc_error: -+ logger.error(f"Failed to process message {message_id}: {proc_error}") -+ # Message remains unacknowledged and can be reclaimed -+ else: -+ # Heartbeat for standby mode -+ await asyncio.sleep(60) -+ logger.debug("❤️ Orchestrator heartbeat") - - except Exception as e: -+ # Force the group to be re-ensured next iteration: the failure may be a -+ # dropped connection or a missing group (NOGROUP) that needs re-creating. -+ group_ready = False - logger.error(f"Error in orchestrator loop: {e}") - await asyncio.sleep(5) - -+ if redis_client: -+ await redis_client.aclose() -+ - logger.info("👋 Orchestrator shutting down") - - if __name__ == "__main__": -diff --git a/tests/unit/test_orchestrator_consumer.py b/tests/unit/test_orchestrator_consumer.py -new file mode 100644 -index 000000000..c018bfa59 ---- /dev/null -+++ b/tests/unit/test_orchestrator_consumer.py -@@ -0,0 +1,78 @@ -+"""Unit tests for youtube_extension/orchestrator/main.py. -+ -+Covers the hardened Redis Streams consumer-group bootstrap (the paths this PR is -+meant to harden) plus the credential-redaction and stub-handler contracts. The -+Redis client is mocked, so these run without a live Redis or the redis-py package. -+""" -+ -+from __future__ import annotations -+ -+from unittest.mock import AsyncMock -+ -+import pytest -+ -+from youtube_extension.orchestrator.main import ( -+ ensure_consumer_group, -+ process, -+ redact_url, -+) -+ -+# --------------------------------------------------------------------------- -+# ensure_consumer_group — the core of the hardening fix -+# --------------------------------------------------------------------------- -+ -+async def test_ensure_consumer_group_creates_when_absent() -> None: -+ client = AsyncMock() -+ await ensure_consumer_group(client, "stream", "group") -+ client.xgroup_create.assert_awaited_once_with( -+ "stream", "group", id="0", mkstream=True -+ ) -+ -+ -+async def test_ensure_consumer_group_tolerates_busygroup() -> None: -+ client = AsyncMock() -+ client.xgroup_create.side_effect = Exception( -+ "BUSYGROUP Consumer Group name already exists" -+ ) -+ # Must NOT raise: an existing group is the expected idempotent case. -+ await ensure_consumer_group(client, "stream", "group") -+ -+ -+async def test_ensure_consumer_group_reraises_transient_errors() -> None: -+ client = AsyncMock() -+ client.xgroup_create.side_effect = Exception( -+ "Error 111 connecting to localhost:6379. Connection refused." -+ ) -+ # A transient ConnectionError must propagate so the caller retries instead of -+ # silently proceeding without a group (which would stall on NOGROUP forever). -+ with pytest.raises(Exception, match="Connection refused"): -+ await ensure_consumer_group(client, "stream", "group") -+ -+ -+# --------------------------------------------------------------------------- -+# redact_url — credentials must never reach logs -+# --------------------------------------------------------------------------- -+ -+async def test_redact_url_strips_credentials() -> None: -+ redacted = redact_url("redis://admin:supersecret@redis.internal:6379/1") -+ assert "supersecret" not in redacted -+ assert "redis.internal" in redacted -+ -+ -+async def test_redact_url_passthrough_without_credentials() -> None: -+ assert redact_url("redis://localhost:6379") == "redis://localhost:6379" -+ -+ -+async def test_redact_url_never_raises_on_garbage() -> None: -+ # Malformed input must degrade to a safe placeholder, never throw. -+ assert redact_url("::not a url::") is not None -+ -+ -+# --------------------------------------------------------------------------- -+# process — REAL_MODE_ONLY: no silent fake success -+# --------------------------------------------------------------------------- -+ -+async def test_process_fails_loudly_until_implemented() -> None: -+ # The stub must raise so the consumer never xack's unprocessed work. -+ with pytest.raises(NotImplementedError): -+ await process({"field": "value"}) diff --git a/Dockerfile b/Dockerfile index f0943d9f2..4e37511d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ # Dockerfile for EventRelay - Hybrid Python + Node.js (v22) -# Multi-stage build optimized for production +# Optimized for Cloud Run and npm workspaces # Stage 1: Builder -FROM python:3.11-slim AS builder +FROM python:3.12-slim AS builder WORKDIR /app -# Install system dependencies +# Install system dependencies: ffmpeg, nodejs, build tools RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ gnupg \ @@ -21,29 +21,25 @@ COPY pyproject.toml requirements.txt* ./ COPY package.json package-lock.json ./ COPY apps/web/package.json ./apps/web/ -# Copy local file dependencies for npm workspace +# Copy local file: dependencies for npm COPY src/dataconnect-generated ./src/dataconnect-generated COPY apps/web/src/dataconnect-generated ./apps/web/src/dataconnect-generated -# Install Python dependencies +# Install dependencies RUN pip install --no-cache-dir --upgrade pip && \ - (pip install --no-cache-dir -r requirements.txt || pip install --no-cache-dir -e .) + pip install --no-cache-dir -r requirements.txt || pip install --no-cache-dir -e . -# Install Node.js dependencies for the web app -# Using workspace to ensure proper hoisting and dependency resolution -RUN npm ci --workspace=apps/web --production --legacy-peer-deps +RUN npm ci --workspace=apps/web --legacy-peer-deps # Stage 2: Runtime -FROM python:3.11-slim AS runtime +FROM python:3.12-slim AS runtime WORKDIR /app -# Install runtime system dependencies (ffmpeg and nodejs v22) -# gnupg is required for the Nodesource setup script +# Install runtime system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg \ curl \ - gnupg \ ca-certificates \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y nodejs \ @@ -53,20 +49,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN groupadd --gid 1000 appuser && \ useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser -# Copy installed Python packages from builder -COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +# Copy installed Python packages +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages COPY --from=builder /usr/local/bin /usr/local/bin -# Copy installed Node.js packages from builder +# Copy installed Node.js packages (hoisted) COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/apps/web/node_modules ./apps/web/node_modules -# Copy local dataconnect artifacts to avoid dangling symlinks +# Copy local file: dependencies to avoid dangling symlinks COPY --from=builder /app/src/dataconnect-generated ./src/dataconnect-generated COPY --from=builder /app/apps/web/src/dataconnect-generated ./apps/web/src/dataconnect-generated -# Copy application code with correct ownership -COPY --chown=appuser:appuser . . +# Copy application code +COPY . . + +# Set permissions +RUN chown -R appuser:appuser /app # Environment variables ENV PORT=8080 @@ -82,7 +81,8 @@ EXPOSE 8080 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ - CMD curl -f http://localhost:${PORT}/health || exit 1 + CMD curl -f http://localhost:${PORT:-8080}/health || exit 1 -# Default command (shell form for $PORT expansion) -CMD python -m uvicorn youtube_extension.main:app --host 0.0.0.0 --port ${PORT} +# Default command (starts backend) +# Use shell-form to support $PORT expansion at runtime +CMD python -m uvicorn youtube_extension.main:app --host 0.0.0.0 --port ${PORT:-8080} diff --git a/LAUNCH_CHECKLIST.md b/LAUNCH_CHECKLIST.md index 76b217533..715ee9519 100644 --- a/LAUNCH_CHECKLIST.md +++ b/LAUNCH_CHECKLIST.md @@ -28,45 +28,25 @@ billing/auth vars go in `apps/web/.env.local` (or your Vercel project settings), ## 1. Launch-gating blockers (must do) -### 1.1 Stripe products & prices — ⏳ TEST MODE (LIVE prices NOT yet created) +### 1.1 Stripe products & prices — ✅ DONE (LIVE mode, 2026-07-02) +Created via the Stripe API on the UVAI account (`acct_1ScN2hAmTgsI2zgN`): +- Product **EventRelay Pro**: `prod_UoUsOjo63AUHAk` +- **$19/mo** recurring Price: `price_1Tos02AmTgsI2zgNWx7onroJ` +- **$180/yr** recurring Price: `price_1Tos0AAmTgsI2zgNSu5lwBv6` -> **Reality check (verified 2026-07-14):** production checkout runs in Stripe -> **TEST mode**. The LIVE prices this section used to claim as "DONE" are **DEAD** — -> Stripe now returns `No such price` for them (evidence: -> `docs/control-plane/sessions/gate3-reprobe-20260714T2011Z/renew-empty.body`). -> **DO NOT re-apply `price_1Tos02AmTgsI2zgNWx7onroJ` or -> `price_1Tos0AAmTgsI2zgNSu5lwBv6`** — they were reverted and will 500 checkout. - -**Current production prices (Stripe TEST mode, account `acct_1ScN2hAmTgsI2zgN`):** - -- **$19/mo** (test): `price_1TtCZXPPnkyjEyFR8dYmDo52` — produces `cs_test_` sessions -- **$180/yr** (test): `price_1TtCZYPPnkyjEyFRLMLPjmzE` - -The Vercel Production env carries the **env-var names** below (do not hardcode any -price ID as "done" in this doc — the authoritative IDs live only in Vercel/Stripe): +Set in the Vercel project env (Production): ``` - STRIPE_SECRET_KEY=sk_test_... # currently TEST; swap to sk_live_ at cutover - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... - STRIPE_WEBHOOK_SECRET=whsec_... # from step 1.2 (configured — verified) - STRIPE_PRICE_PRO_MONTHLY= - STRIPE_PRICE_PRO_ANNUAL= + STRIPE_SECRET_KEY=sk_live_... # Dashboard → Developers → API keys + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... + STRIPE_WEBHOOK_SECRET=whsec_... # from step 1.2 + STRIPE_PRICE_PRO_MONTHLY=price_1Tos02AmTgsI2zgNWx7onroJ + STRIPE_PRICE_PRO_ANNUAL=price_1Tos0AAmTgsI2zgNSu5lwBv6 ``` - Without the two `STRIPE_PRICE_*` IDs, `requireStripePriceId()` throws and - checkout 500s. Price IDs are not secrets; the `sk_` key and `whsec_` secret are. - -**🔴 LIVE cutover (required before real revenue):** create a fresh LIVE-mode -Product + recurring Prices on `acct_1ScN2hAmTgsI2zgN`, record the NEW live price -IDs, set them plus `sk_live_` / `pk_live_` / a live `whsec_` in Vercel Production, -then re-run the gate3 probe and confirm the renew session returns `cs_live_` -(not `cs_test_`) with no "No such price" error **before** charging real cards. - -> **Local drift:** `apps/web/.env.local` (gitignored) currently sets a THIRD -> divergent pair (`price_1TnYlW…`) matching neither prod nor the dead IDs. -> Reconcile it to the TEST IDs above for local↔prod parity. + checkout 500s. Price IDs are not secrets (they appear in checkout URLs); + the `sk_live_` key and `whsec_` secret are. -### 1.2 Stripe webhook endpoint — ✅ CONFIGURED (test mode; redo for live at cutover) -Verified 2026-07-14: unsigned POST → `400 missing_signature` (not 503), bad signature → `400`. `STRIPE_WEBHOOK_SECRET` is live in Vercel Production for endpoint `we_1TtCYr…`. At LIVE cutover, create a new **live-mode** webhook and swap in its `whsec_`. -Original setup steps (for the live re-do): +### 1.2 Stripe webhook endpoint (manual — 1 minute, live mode) - In Stripe Dashboard (live mode) → Developers → Webhooks, add an endpoint: `https://uvai.io/api/billing/webhook`. - Subscribe to: `checkout.session.completed`, @@ -75,8 +55,9 @@ Original setup steps (for the live re-do): - The handler (`api/billing/webhook/route.ts`) returns 503 until this is set. - (Webhook creation isn't exposed via the Stripe MCP, hence manual.) -### 1.3 Cloudflare Turnstile (checkout bot-gate) — ✅ LIVE (verified 2026-07-14) -Live keys are set in Vercel Production and validating: fake token → `403 turnstile_verification_failed` (a configured, working gate — not `turnstile_not_configured`). `/api/billing/checkout` is gated by Turnstile; unset → **every new subscriber gets 403**. +### 1.3 Cloudflare Turnstile (checkout bot-gate) +`/api/billing/checkout` is gated by Turnstile; unset → **every new subscriber +gets 403**. - Create a Turnstile widget at Cloudflare → get site key + secret. - Set in `apps/web/.env.local`: ``` @@ -87,8 +68,8 @@ Live keys are set in Vercel Production and validating: fake token → `403 turns `apps/web/.env.example`): site `1x00000000000000000000AA`, secret `1x0000000000000000000000000000000AA`. -### 1.4 Upstash Redis (durable entitlements) — ⏳ UNVERIFIED in prod -Code confirms the guard is correct (REST-only: reads `UPSTASH_REDIS_REST_URL/TOKEN` or `KV_REST_API_URL/TOKEN`; no `redis://`/ioredis path), but whether a Vercel integration is actually injecting those REST creds into Production **cannot be confirmed from the repo** (sensitive env). Verify on the integration page, or prove it by completing one paid E2E and checking the entitlement persists. Paid status must survive serverless cold starts / multiple instances. In +### 1.4 Upstash Redis (durable entitlements) — use the Vercel integration +Paid status must survive serverless cold starts / multiple instances. In production `assertEntitlementDurability()` **throws on boot** without Upstash. - **Easiest path:** install the Upstash integration from the Vercel project's Integrations settings (`vercel.com///settings/integrations`) @@ -99,8 +80,8 @@ production `assertEntitlementDurability()` **throws on boot** without Upstash. - Manual alternative: create a DB at upstash.com and set the two vars yourself in `apps/web/.env.local` / Vercel env. -### 1.5 Google OAuth + NextAuth (sign-in) — ✅ LIVE (providers 200) -Verified 2026-07-14: `/api/auth/providers` returns Google and `/api/auth/csrf` returns a token, so `NEXTAUTH_SECRET` + Google creds are set in Production. Auth is Google-only and stays **off until `NEXTAUTH_SECRET` is set**. +### 1.5 Google OAuth + NextAuth (sign-in) +Auth is Google-only and stays **off until `NEXTAUTH_SECRET` is set**. - Create a Google OAuth app (Authorized redirect URI: `https:///api/auth/callback/google`). - Set in `apps/web/.env.local`: @@ -168,8 +149,10 @@ Vercel has none by default, so `/api/agents/dispatch` returns 503. - **Webhook robustness:** add idempotency keys and handle `invoice.payment_failed` (dunning) for recurring-revenue reliability (`api/billing/webhook/route.ts`). -- ~~**Dead code:** `src/integration/routes.py` — removed (imported non-existent - `src.integrations` package and was never mounted).~~ +- **Dead code:** `src/integration/routes.py` (the "monetize generated apps" + feature, unrelated to subscriptions) imports a non-existent `src.integrations` + package and is not mounted anywhere. Fix its imports + package exports, or + remove it, before wiring it up. - **Backend install hygiene:** `pip install -e .[dev]` against a system Python with Debian's `packaging` can fail (`RECORD file not found`); always use a clean venv for the backend. diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 05a07cc6a..67e6fa6ac 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -2,11 +2,8 @@ import type { NextRequest, NextResponse } from 'next/server'; import { proxy } from '@/proxy'; /** - * Next.js middleware entrypoint. - * - * Runs login gating + rate limiting from `src/proxy.ts` for: - * - /dashboard and nested product routes (session required when NEXTAUTH_SECRET is set) - * - /api/* (session required except public allowlist in `@/lib/auth-paths`) + * Standard Next.js middleware that activates the rate limiting logic from src/proxy.ts + * for all /api/* routes. This makes the rate limiter "active" as claimed in runbooks. * * See config/agent_network.json (rate-limit-middleware agent) and the confirmed * remediation outcome + verification methods for full context. @@ -20,9 +17,5 @@ export async function middleware(request: NextRequest): Promise { } export const config = { - matcher: [ - '/dashboard', - '/dashboard/:path*', - '/api/:path*', - ], + matcher: ['/api/:path*'], }; diff --git a/apps/web/next.config.js b/apps/web/next.config.js index bd9197027..0a0c9224e 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,11 +1,5 @@ const path = require('path'); - -let withSentryConfig = (config) => config; -try { - ({ withSentryConfig } = require('@sentry/nextjs')); -} catch { - // Allow builds to continue when optional Sentry runtime peers are unavailable. -} +const { withSentryConfig } = require('@sentry/nextjs'); const contentSecurityPolicy = [ "default-src 'self'", diff --git a/apps/web/package.json b/apps/web/package.json index 54cc3cf67..c7b5cd929 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,56 +7,56 @@ "build": "next build --webpack", "start": "next start", "lint": "eslint src middleware.ts", - "type-check": "tsc --noEmit", "test": "vitest run", + "type-check": "tsc --noEmit", "analyze": "next experimental-analyze --output" }, "dependencies": { - "@ai-sdk/gateway": "^4.0.19", + "@ai-sdk/gateway": "^4.0.12", "@dataconnect/generated": "file:src/dataconnect-generated", - "@google/genai": "^2.11.0", + "@google/genai": "^2.10.0", "@google/generative-ai": "^0.24.1", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.9.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", - "@opentelemetry/semantic-conventions": "^1.43.0", - "@sentry/nextjs": "^10.65.0", + "@opentelemetry/semantic-conventions": "^1.41.0", + "@sentry/nextjs": "^10.63.0", "@stripe/stripe-js": "^9.9.0", - "@supabase/supabase-js": "^2.110.5", + "@supabase/supabase-js": "^2.110.0", "@upstash/redis": "^1.38.0", "@upstash/search": "^0.1.7", "@vercel/analytics": "^2.0.1", "@vercel/functions": "^3.7.5", "@vercel/speed-insights": "^2.0.0", - "ai": "^7.0.26", + "ai": "^7.0.15", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", - "lucide-react": "^1.24.0", + "lucide-react": "^1.23.0", "next": "^16.2.10", "next-auth": "^4.24.14", - "openai": "^6.46.0", + "openai": "^6.45.0", "react": "^19", "react-dom": "^19", "server-only": "^0.0.1", - "stripe": "^22.3.1", + "stripe": "^22.3.0", "tailwind-merge": "^3.6.0", "use-sync-external-store": "^1.6.0", "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4.3.2", "@types/node": "^26", "@types/react": "^19", "@types/react-dom": "^19", "autoprefixer": "^10.5.2", - "eslint": "^9.39.5", + "eslint": "^9.39.0", "eslint-config-next": "^16.2.10", "playwright": "^1.61.1", - "postcss": "^8.5.19", + "postcss": "^8.5.16", "tailwindcss": "^4.3.1", "typescript": "^6.0.3", "vite": "^8.1.3", @@ -64,7 +64,7 @@ }, "overrides": { "@protobufjs/utf8": "^1.1.1", - "postcss": "^8.5.19", + "postcss": "^8.5.16", "protobufjs": "^7.6.2", "qs": "^6.15.2", "uuid": "^11.1.1", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 000000000..10119979d --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + webServer: { + command: "npm run dev", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + stdout: "pipe", + stderr: "pipe", + }, + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/apps/web/src/app/api/__tests__/pipeline-route.test.ts b/apps/web/src/app/api/__tests__/pipeline-route.test.ts index 2bba74956..192b33b84 100644 --- a/apps/web/src/app/api/__tests__/pipeline-route.test.ts +++ b/apps/web/src/app/api/__tests__/pipeline-route.test.ts @@ -119,7 +119,7 @@ describe('POST /api/pipeline', () => { vi.mocked(parseBackendJson).mockResolvedValue(null); const res = await POST(postRequest({ - url: 'https://www.youtube.com/watch?v=auJzb1D-fag', + url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', async: true, })); const body = await res.json(); @@ -159,7 +159,7 @@ describe('POST /api/pipeline', () => { }); const res = await POST(postRequest({ - url: 'https://www.youtube.com/watch?v=auJzb1D-fag', + url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', async: false, })); const body = await res.json(); @@ -182,7 +182,7 @@ describe('POST /api/pipeline', () => { vi.mocked(hasGeminiKey).mockReturnValue(false); const res = await POST(postRequest({ - url: 'https://www.youtube.com/watch?v=auJzb1D-fag', + url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', project_type: 'automation', deployment_target: 'vercel', })); diff --git a/apps/web/src/app/api/__tests__/video-generate-route.test.ts b/apps/web/src/app/api/__tests__/video-generate-route.test.ts index f8822ac8f..08eb93a43 100644 --- a/apps/web/src/app/api/__tests__/video-generate-route.test.ts +++ b/apps/web/src/app/api/__tests__/video-generate-route.test.ts @@ -1,47 +1,15 @@ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; -import { POST } from '@/app/api/video/generate/route'; - -// Mock billing modules -vi.mock('@/lib/billing/billing-context', () => ({ - resolveTrustedBillingEmail: vi.fn(async () => 'pro@example.com'), -})); vi.mock('@/lib/billing/entitlement-store', () => ({ - isProSubscriber: vi.fn(async (email: string) => email === 'pro@example.com'), -})); - -// Mock redis-credentials -vi.mock('@/lib/billing/redis-credentials', () => ({ - resolveUpstashRedisCredentials: vi.fn(() => ({ url: 'https://test.upstash.io', token: 'test-token' })), -})); - -// Mock @upstash/redis -const redisIncrMock = vi.fn(); -const redisExpireMock = vi.fn(); -vi.mock('@upstash/redis', () => ({ - Redis: function() { - return { - incr: redisIncrMock, - expire: redisExpireMock, - }; - }, + isProSubscriber: vi.fn().mockResolvedValue(true), })); -// Mock aiGateway -vi.mock('@/lib/ai-gateway', () => ({ - aiGateway: { - videoModel: vi.fn(() => 'mock-model'), - }, - GATEWAY_VIDEO_MODEL: 'google/veo-3.1-generate-001', -})); +import { POST } from '@/app/api/video/generate/route'; -// Mock ai -const generateVideoMock = vi.fn(); -vi.mock('ai', () => ({ - experimental_generateVideo: (...args: any[]) => generateVideoMock(...args), -})); +const GATEWAY_URL = 'https://ai-gateway.vercel.sh/v1/video/generations'; -/** Build a POST request with a per-test client IP */ +/** Build a POST request with a per-test client IP so the module-scoped rate + * limiter doesn't bleed between tests. Pass `raw` to send a non-JSON body. */ function postReq(body: unknown, ip = '10.0.0.1', raw = false) { return new Request('http://localhost:3000/api/video/generate', { method: 'POST', @@ -50,30 +18,46 @@ function postReq(body: unknown, ip = '10.0.0.1', raw = false) { }); } +function gatewayOk(json: unknown) { + return { ok: true, status: 200, json: async () => json, text: async () => JSON.stringify(json) }; +} +function gatewayErr(status: number) { + return { ok: false, status, json: async () => ({}), text: async () => 'gateway error' }; +} +function streamOf(text: string) { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); +} +function videoBytesOk(text = 'FAKEVIDEO') { + return { + ok: true, + status: 200, + body: streamOf(text), + headers: new Headers({ 'content-type': 'video/mp4', 'content-length': String(text.length) }), + }; +} + const validBody = { prompt: 'a calm ocean at sunset', aspectRatio: '16:9', duration: 5 }; beforeEach(() => { - vi.clearAllMocks(); - redisIncrMock.mockResolvedValue(1); - generateVideoMock.mockResolvedValue({ - video: { - uint8Array: new Uint8Array([1, 2, 3, 4]), - mediaType: 'video/mp4', - }, - }); + process.env.AI_GATEWAY_API_KEY = 'test-key'; + global.fetch = vi.fn(); }); afterEach(() => { vi.restoreAllMocks(); + delete process.env.AI_GATEWAY_API_KEY; }); describe('POST /api/video/generate', () => { - it('returns 402 when user is not a Pro subscriber', async () => { - const { resolveTrustedBillingEmail } = await import('@/lib/billing/billing-context'); - (resolveTrustedBillingEmail as any).mockResolvedValueOnce('free@example.com'); - - const res = await POST(postReq(validBody, '10.0.0.1')); - expect(res.status).toBe(402); + it('returns 503 when AI_GATEWAY_API_KEY is not configured', async () => { + delete process.env.AI_GATEWAY_API_KEY; + const res = await POST(postReq(validBody, '10.0.0.2')); + expect(res.status).toBe(503); }); it('returns 400 on invalid JSON body', async () => { @@ -101,50 +85,69 @@ describe('POST /api/video/generate', () => { expect(res.status).toBe(400); }); - it('enforces the Redis rate limit (4th request within window → 429)', async () => { - redisIncrMock.mockResolvedValue(4); - const res = await POST(postReq(validBody, '10.9.9.9')); - expect(res.status).toBe(429); - expect(redisIncrMock).toHaveBeenCalledWith('ratelimit:video-generate:10.9.9.9'); + it('enforces the per-IP rate limit (4th request within window → 429)', async () => { + (global.fetch as ReturnType).mockResolvedValue( + gatewayOk({ data: [{ b64_json: 'AAAA' }] }) as unknown as Response + ); + const ip = '10.9.9.9'; + for (let i = 0; i < 3; i++) { + const ok = await POST(postReq(validBody, ip)); + expect(ok.status).toBe(200); + } + const limited = await POST(postReq(validBody, ip)); + expect(limited.status).toBe(429); }); - it('sets expiration on the first request for an IP', async () => { - redisIncrMock.mockResolvedValue(1); - await POST(postReq(validBody, '10.1.1.1')); - expect(redisExpireMock).toHaveBeenCalledWith('ratelimit:video-generate:10.1.1.1', 600); + it('propagates a gateway error status', async () => { + (global.fetch as ReturnType).mockResolvedValue(gatewayErr(500) as unknown as Response); + const res = await POST(postReq(validBody, '10.0.0.8')); + expect(res.status).toBe(500); }); - it('streams the bytes when generation is successful', async () => { - const fakeVideoData = new Uint8Array([1, 2, 3, 4]); - generateVideoMock.mockResolvedValue({ - video: { - uint8Array: fakeVideoData, - mediaType: 'video/mp4', - }, - }); + it('returns 502 when the gateway response has no video', async () => { + (global.fetch as ReturnType).mockResolvedValue( + gatewayOk({ data: [{}] }) as unknown as Response + ); + const res = await POST(postReq(validBody, '10.0.0.9')); + expect(res.status).toBe(502); + }); + it('streams the decoded bytes when the gateway provides base64 inline', async () => { + // 'QkFTRTY0' is base64 for 'BASE64' + (global.fetch as ReturnType).mockResolvedValue( + gatewayOk({ data: [{ b64_json: 'QkFTRTY0' }] }) as unknown as Response + ); const res = await POST(postReq(validBody, '10.0.0.10')); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('video/mp4'); - expect(res.headers.get('x-video-model')).toBe('google/veo-3.1-generate-001'); - - const buf = await res.arrayBuffer(); - expect(new Uint8Array(buf)).toEqual(fakeVideoData); + const buf = Buffer.from(await res.arrayBuffer()); + expect(buf.toString()).toBe('BASE64'); }); - it('propagates TimeoutError as 504', async () => { - const timeoutErr = new Error('Timeout'); - timeoutErr.name = 'TimeoutError'; - generateVideoMock.mockRejectedValue(timeoutErr); + it('streams a remote signed URL through without base64-in-JSON (CSP-safe, no client proxy)', async () => { + const fetchMock = global.fetch as ReturnType; + fetchMock + .mockResolvedValueOnce(gatewayOk({ data: [{ url: 'https://cdn.example/signed.mp4' }] }) as unknown as Response) + .mockResolvedValueOnce(videoBytesOk() as unknown as Response); const res = await POST(postReq(validBody, '10.0.0.11')); - expect(res.status).toBe(504); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('video/mp4'); + const buf = Buffer.from(await res.arrayBuffer()); + expect(buf.toString()).toBe('FAKEVIDEO'); + // second fetch was the server-side retrieval of the gateway-provided URL + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][0]).toBe(GATEWAY_URL); + expect(fetchMock.mock.calls[1][0]).toBe('https://cdn.example/signed.mp4'); }); - it('returns 500 on other generation errors', async () => { - generateVideoMock.mockRejectedValue(new Error('Gateway failed')); + it('returns 502 when the signed URL cannot be retrieved', async () => { + const fetchMock = global.fetch as ReturnType; + fetchMock + .mockResolvedValueOnce(gatewayOk({ data: [{ url: 'https://cdn.example/signed.mp4' }] }) as unknown as Response) + .mockResolvedValueOnce({ ok: false, status: 404, body: null, headers: new Headers() } as unknown as Response); const res = await POST(postReq(validBody, '10.0.0.12')); - expect(res.status).toBe(500); + expect(res.status).toBe(502); }); }); diff --git a/apps/web/src/app/api/pipeline/stream/route.ts b/apps/web/src/app/api/pipeline/stream/route.ts index fbd687f17..35ce34fce 100644 --- a/apps/web/src/app/api/pipeline/stream/route.ts +++ b/apps/web/src/app/api/pipeline/stream/route.ts @@ -240,225 +240,6 @@ async function pollBackendJob( throw new Error('Timed out waiting for async video job to complete (job status never reached complete or failed)'); } -/** - * Schedule ancillary background work after the pipeline stream completes. - * Fires-and-forgets (via waitUntil) training-example saving, embedding - * generation, a PIPELINE_COMPLETED CloudEvent, and search indexing — none - * of which block the response stream. - */ -function schedulePostProcessing(videoUrl: string, analysis: VideoAnalysisResult, useBackend: boolean) { - // Direct waitUntil on saveTrainingExample for training save (ancillary, post-response) - // Orchestrated here AFTER pipeline_status:complete events are streamed. - waitUntil( - saveTrainingExample( - videoUrl, - analysis as unknown as Record, - ).then(({ saved, metadata, milestone }) => { - if (saved && milestone) { - console.log(`\n🎯 TRAINING MILESTONE: ${milestone}/${TUNING_THRESHOLD} examples collected!`); - if (milestone >= TUNING_THRESHOLD) { - console.log('🚀 READY FOR FINE-TUNING! Call POST /api/training/trigger to start.'); - } - } - if (saved) { - console.log(`[Training] Dataset: ${metadata.totalExamples} examples`); - } else { - console.log(`[Training] Skipped duplicate: ${videoUrl}`); - } - }).catch((err) => { - console.warn(`[Training] Background task failed (non-fatal):`, err); - }), - ); - - // Embeddings — direct waitUntil on the post-processing promise (includes saveEmbeddings) - waitUntil( - (async () => { - let segments = analysis.transcript; - if (!segments || segments.length === 0) { - const { fetchTranscript } = await import('@/lib/transcription-service'); - const result = await fetchTranscript({ url: videoUrl }); - if (result.success && result.segments && result.segments.length > 0) { - segments = result.segments.map(s => ({ - start: s.start, - duration: s.duration, - text: s.text || '' - })); - } - } - - if (segments && segments.length > 0) { - const { chunkTranscript, generateEmbeddingsForChunks } = await import('@/lib/gemini-embedding'); - const { saveEmbeddings } = await import('@/lib/embedding-store'); - const chunks = chunkTranscript(segments); - const embeddedChunks = await generateEmbeddingsForChunks(chunks); - const videoId = videoUrl.match(/[?&]v=([^&]+)/)?.[1] || videoUrl.replace(/[^a-zA-Z0-9_-]/g, '_'); - await saveEmbeddings(videoId, embeddedChunks); - } - })().catch((err) => { - console.warn(`[Embeddings] Background task failed (non-fatal):`, err); - }), - ); - - // CloudEvent — direct waitUntil on publishEvent - waitUntil( - publishEvent(EventTypes.PIPELINE_COMPLETED, { - strategy: useBackend ? 'backend-proxy' : 'gemini-stream', - success: true, - }, videoUrl).catch((err) => { - console.warn(`[CloudEvent] Background task failed (non-fatal):`, err); - }), - ); - - // Durable cross-video search index (Upstash) — unlike the local-disk - // stores above, this persists on Vercel's read-only filesystem. - // Skips honestly when UPSTASH_SEARCH_* env is absent. - waitUntil( - (async () => { - const { indexVideoAnalysis } = await import('@/lib/search-indexer'); - await indexVideoAnalysis(videoUrl, analysis); - })().catch((err) => { - console.warn(`[SearchIndex] Background task failed (non-fatal):`, err); - }), - ); -} - - - - -async function handleBackendStrategy( - url: string, - backendUrl: string, - controller: ReadableStreamDefaultController, - encoder: TextEncoder, - deadline: PipelineDeadline, - startTime: number -) { - // Strategy 1: Proxy from backend - try { - const response = await fetch(`${backendUrl}/api/v1/transcript-action`, { - method: 'POST', - headers: backendHeaders(), - body: JSON.stringify({ video_url: url, language: 'en' }), - signal: deadline.signalFor(STREAM_BACKEND_KICKOFF_MS), - }); - - if (response.ok) { - const result = await response.json(); - const transcriptResult = result as BackendTranscriptActionResponse; - if (transcriptResult.async_processing && transcriptResult.status_url) { - controller.enqueue( - encoder.encode( - makeEvent({ - type: 'agent_update', - agentId: 'async_queue', - agentName: 'AsyncVideoQueue', - status: 'running', - progress: 5, - data: { - jobId: transcriptResult.job_id, - transport: transcriptResult.processing_transport, - }, - timestamp: new Date().toISOString(), - }), - ), - ); - - const statusUrl = resolveBackendStatusUrl( - transcriptResult.status_url, - backendUrl, - ); - const job = await pollBackendJob(statusUrl, controller, encoder, deadline); - if (job.status === 'failed') { - throw new Error(job.error || 'Async transcript job failed'); - } - - const mappedAnalysis = mapBackendResultToAnalysis({ - metadata: job.metadata?.metadata || {}, - transcript: { - text: job.transcript || '', - segments: [], - }, - outputs: job.metadata?.outputs || {}, - }); - - // Stream all agent events including pipeline_status:complete - for await (const event of generateAgentEvents(mappedAnalysis, startTime)) { - controller.enqueue(encoder.encode(event)); - } - - // Schedule optional work AFTER stream events (incl. pipeline_status:complete) are done — direct waitUntil inside - schedulePostProcessing(url, mappedAnalysis, true); - return; - } - - const mappedAnalysis = mapBackendResultToAnalysis(result); - - // Stream all agent events including pipeline_status:complete - for await (const event of generateAgentEvents(mappedAnalysis, startTime)) { - controller.enqueue(encoder.encode(event)); - } - - // Schedule optional work — direct waitUntil (after complete events) - schedulePostProcessing(url, mappedAnalysis, true); - } else { - throw new Error(`Backend returned ${response.status}`); - } - } catch (backendErr) { - // Fall through to Gemini if backend fails - console.warn('Backend stream failed, falling through to Gemini:', backendErr); - if (hasGeminiKey() && deadline.remainingMs() > 1_000) { - await handleGeminiStrategy(url, true, controller, encoder, deadline, startTime); - } else { - controller.enqueue( - encoder.encode( - makeEvent({ - type: 'error', - data: { message: 'Backend unavailable and no Gemini key configured' }, - timestamp: new Date().toISOString(), - }), - ), - ); - } - } -} - -async function handleGeminiStrategy( - url: string, - useBackend: boolean, - controller: ReadableStreamDefaultController, - encoder: TextEncoder, - deadline: PipelineDeadline, - startTime: number -) { - // Strategy 2: Direct Gemini analysis - // Only publish TRANSCRIPT_STARTED on the direct Gemini path; the backend - // fallback path must not emit this event (it was absent in the original code). - if (!useBackend) { - waitUntil( - publishEvent(EventTypes.TRANSCRIPT_STARTED, { url, strategy: 'gemini-stream' }, url).catch((err) => { - console.warn('[CloudEvent] TRANSCRIPT_STARTED failed (non-fatal):', err); - }), - ); - } - - const analysis = await deadline.runWithBudget( - analyzeVideoWithGemini(url), - deadline.remainingMs(), - // Preserve the original operator-facing distinction: a backend failure - // that falls through to Gemini logs 'Gemini stream fallback', while the - // direct Gemini path logs 'Gemini stream analysis'. - useBackend ? 'Gemini stream fallback' : 'Gemini stream analysis', - ); - - // Stream all agent events including pipeline_status:complete - for await (const event of generateAgentEvents(analysis, startTime)) { - controller.enqueue(encoder.encode(event)); - } - - // Schedule optional work via direct waitUntil (non-blocking, after complete) - schedulePostProcessing(url, analysis, useBackend); -} - /** * Convert a full Gemini analysis result into a timed sequence of SSE events * that mimic the multi-agent pipeline execution agents would produce. @@ -787,11 +568,202 @@ export async function POST(request: Request) { // See: https://github.com/groupthinking/EventRelay/issues/139 // Direct waitUntil (no fireAndForget, no bare top-level .catch) per ancillary paths standard. + const schedulePostProcessing = (videoUrl: string, analysis: VideoAnalysisResult) => { + // Direct waitUntil on saveTrainingExample for training save (ancillary, post-response) + // Orchestrated here AFTER pipeline_status:complete events are streamed. + waitUntil( + saveTrainingExample( + videoUrl, + analysis as unknown as Record, + ).then(({ saved, metadata, milestone }) => { + if (saved && milestone) { + console.log(`\n🎯 TRAINING MILESTONE: ${milestone}/${TUNING_THRESHOLD} examples collected!`); + if (milestone >= TUNING_THRESHOLD) { + console.log('🚀 READY FOR FINE-TUNING! Call POST /api/training/trigger to start.'); + } + } + if (saved) { + console.log(`[Training] Dataset: ${metadata.totalExamples} examples`); + } else { + console.log(`[Training] Skipped duplicate: ${videoUrl}`); + } + }).catch((err) => { + console.warn(`[Training] Background task failed (non-fatal):`, err); + }), + ); + + // Embeddings — direct waitUntil on the post-processing promise (includes saveEmbeddings) + waitUntil( + (async () => { + let segments = analysis.transcript; + if (!segments || segments.length === 0) { + const { fetchTranscript } = await import('@/lib/transcription-service'); + const result = await fetchTranscript({ url: videoUrl }); + if (result.success && result.segments && result.segments.length > 0) { + segments = result.segments.map(s => ({ + start: s.start, + duration: s.duration, + text: s.text || '' + })); + } + } + + if (segments && segments.length > 0) { + const { chunkTranscript, generateEmbeddingsForChunks } = await import('@/lib/gemini-embedding'); + const { saveEmbeddings } = await import('@/lib/embedding-store'); + const chunks = chunkTranscript(segments); + const embeddedChunks = await generateEmbeddingsForChunks(chunks); + const videoId = videoUrl.match(/[?&]v=([^&]+)/)?.[1] || videoUrl.replace(/[^a-zA-Z0-9_-]/g, '_'); + await saveEmbeddings(videoId, embeddedChunks); + } + })().catch((err) => { + console.warn(`[Embeddings] Background task failed (non-fatal):`, err); + }), + ); + + // CloudEvent — direct waitUntil on publishEvent + waitUntil( + publishEvent(EventTypes.PIPELINE_COMPLETED, { + strategy: useBackend ? 'backend-proxy' : 'gemini-stream', + success: true, + }, videoUrl).catch((err) => { + console.warn(`[CloudEvent] Background task failed (non-fatal):`, err); + }), + ); + + // Durable cross-video search index (Upstash) — unlike the local-disk + // stores above, this persists on Vercel's read-only filesystem. + // Skips honestly when UPSTASH_SEARCH_* env is absent. + waitUntil( + (async () => { + const { indexVideoAnalysis } = await import('@/lib/search-indexer'); + await indexVideoAnalysis(videoUrl, analysis); + })().catch((err) => { + console.warn(`[SearchIndex] Background task failed (non-fatal):`, err); + }), + ); + }; if (useBackend && backendUrl) { - await handleBackendStrategy(url, backendUrl, controller, encoder, deadline, startTime); + // Strategy 1: Proxy from backend + try { + const response = await fetch(`${backendUrl}/api/v1/transcript-action`, { + method: 'POST', + headers: backendHeaders(), + body: JSON.stringify({ video_url: url, language: 'en' }), + signal: deadline.signalFor(STREAM_BACKEND_KICKOFF_MS), + }); + + if (response.ok) { + const result = await response.json(); + const transcriptResult = result as BackendTranscriptActionResponse; + if (transcriptResult.async_processing && transcriptResult.status_url) { + controller.enqueue( + encoder.encode( + makeEvent({ + type: 'agent_update', + agentId: 'async_queue', + agentName: 'AsyncVideoQueue', + status: 'running', + progress: 5, + data: { + jobId: transcriptResult.job_id, + transport: transcriptResult.processing_transport, + }, + timestamp: new Date().toISOString(), + }), + ), + ); + + const statusUrl = resolveBackendStatusUrl( + transcriptResult.status_url, + backendUrl, + ); + const job = await pollBackendJob(statusUrl, controller, encoder, deadline); + if (job.status === 'failed') { + throw new Error(job.error || 'Async transcript job failed'); + } + + const mappedAnalysis = mapBackendResultToAnalysis({ + metadata: job.metadata?.metadata || {}, + transcript: { + text: job.transcript || '', + segments: [], + }, + outputs: job.metadata?.outputs || {}, + }); + + // Stream all agent events including pipeline_status:complete + for await (const event of generateAgentEvents(mappedAnalysis, startTime)) { + controller.enqueue(encoder.encode(event)); + } + + // Schedule optional work AFTER stream events (incl. pipeline_status:complete) are done — direct waitUntil inside + schedulePostProcessing(url, mappedAnalysis); + return; + } + + const mappedAnalysis = mapBackendResultToAnalysis(result); + + // Stream all agent events including pipeline_status:complete + for await (const event of generateAgentEvents(mappedAnalysis, startTime)) { + controller.enqueue(encoder.encode(event)); + } + + // Schedule optional work — direct waitUntil (after complete events) + schedulePostProcessing(url, mappedAnalysis); + } else { + throw new Error(`Backend returned ${response.status}`); + } + } catch (backendErr) { + // Fall through to Gemini if backend fails + console.warn('Backend stream failed, falling through to Gemini:', backendErr); + if (hasGeminiKey() && deadline.remainingMs() > 1_000) { + const analysis = await deadline.runWithBudget( + analyzeVideoWithGemini(url), + deadline.remainingMs(), + 'Gemini stream fallback', + ); + + // Stream all agent events including pipeline_status:complete + for await (const event of generateAgentEvents(analysis, startTime)) { + controller.enqueue(encoder.encode(event)); + } + + // Schedule optional work — direct waitUntil (after complete events) + schedulePostProcessing(url, analysis); + } else { + controller.enqueue( + encoder.encode( + makeEvent({ + type: 'error', + data: { message: 'Backend unavailable and no Gemini key configured' }, + timestamp: new Date().toISOString(), + }), + ), + ); + } + } } else { - await handleGeminiStrategy(url, useBackend, controller, encoder, deadline, startTime); + // Strategy 2: Direct Gemini analysis + // Start event as true background (non-blocking even for stream setup) — direct waitUntil on publishEvent + waitUntil( + publishEvent(EventTypes.TRANSCRIPT_STARTED, { url, strategy: 'gemini-stream' }, url).catch(() => {}), + ); + + const analysis = await deadline.runWithBudget( + analyzeVideoWithGemini(url), + deadline.remainingMs(), + 'Gemini stream analysis', + ); + + // Stream all agent events including pipeline_status:complete + for await (const event of generateAgentEvents(analysis, startTime)) { + controller.enqueue(encoder.encode(event)); + } + + // Schedule optional work via direct waitUntil (non-blocking, after complete) + schedulePostProcessing(url, analysis); } } catch (err) { console.error('Pipeline stream processing error:', err); diff --git a/apps/web/src/app/api/video/generate/route.ts b/apps/web/src/app/api/video/generate/route.ts index c90a9e224..324f2877c 100644 --- a/apps/web/src/app/api/video/generate/route.ts +++ b/apps/web/src/app/api/video/generate/route.ts @@ -1,56 +1,53 @@ import { NextResponse } from 'next/server'; -import { experimental_generateVideo } from 'ai'; import { resolveTrustedBillingEmail } from '@/lib/billing/billing-context'; import { isProSubscriber } from '@/lib/billing/entitlement-store'; -import { resolveUpstashRedisCredentials } from '@/lib/billing/redis-credentials'; -import { aiGateway, GATEWAY_VIDEO_MODEL } from '@/lib/ai-gateway'; export const runtime = 'nodejs'; // streams/buffers the gateway video bytes through export const maxDuration = 300; // 5 minutes — video generation takes time +/** Simple in-memory rate limiter: max 3 requests per IP per 10 minutes */ +const rateLimitMap = new Map(); const RATE_LIMIT_MAX = 3; -const RATE_LIMIT_WINDOW_SECONDS = 10 * 60; +const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; const ALLOWED_ASPECT_RATIOS = ['16:9', '9:16', '1:1', '4:3']; const MIN_DURATION_SECONDS = 1; const MAX_DURATION_SECONDS = 60; /** - * Durable Redis-backed rate limiter using Upstash. + * Evict expired rate-limit records so the map does not grow unbounded in a + * long-lived server runtime, then apply the limit for the given IP. */ -async function checkRateLimit(ip: string): Promise { - const creds = resolveUpstashRedisCredentials(); - if (!creds) { - // Fallback to allow if Redis is not configured (best-effort) - return true; - } - - try { - const { Redis } = await import('@upstash/redis'); - const redis = new Redis({ - url: creds.url, - token: creds.token, - }); +function checkRateLimit(ip: string): boolean { + const now = Date.now(); - const key = `ratelimit:video-generate:${ip}`; - const count = await redis.incr(key); + for (const [key, record] of rateLimitMap) { + if (now > record.resetAt) { + rateLimitMap.delete(key); + } + } - // Refresh expiration on every hit to ensure we don't leak keys if the - // initial expire call failed. - await redis.expire(key, RATE_LIMIT_WINDOW_SECONDS); + const record = rateLimitMap.get(ip); - return count <= RATE_LIMIT_MAX; - } catch (error) { - console.error('[video/generate] Redis rate limit error:', error); - // Fallback to allow on Redis failure to avoid blocking legitimate users + if (!record || now > record.resetAt) { + rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); return true; } + + if (record.count >= RATE_LIMIT_MAX) { + return false; + } + + record.count++; + return true; } export async function POST(request: Request) { // Veo-3.1 is the most expensive AI operation in the app. Gate it behind the // Pro entitlement like the other paid routes (agents/dispatch), so an - // unauthenticated caller cannot run up video-generation spend. + // unauthenticated caller cannot run up video-generation spend — the per-IP + // in-memory limiter below is per-instance and defeated by autoscaling + IP + // rotation, so it is a secondary control, not the paywall. const billingEmail = await resolveTrustedBillingEmail(request); const isPro = await isProSubscriber(billingEmail); if (!isPro) { @@ -64,15 +61,24 @@ export async function POST(request: Request) { ); } + const apiKey = process.env.AI_GATEWAY_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: 'AI_GATEWAY_API_KEY is not configured.' }, + { status: 503 } + ); + } + // Rate limiting. The x-forwarded-for / x-real-ip headers are only trustworthy // because Vercel's edge network overwrites them with the real client IP before - // the request reaches this function. + // the request reaches this function; do not rely on them in environments where + // an untrusted proxy sits in front of the app. const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? request.headers.get('x-real-ip') ?? 'unknown'; - if (!(await checkRateLimit(ip))) { + if (!checkRateLimit(ip)) { return NextResponse.json( { error: 'Rate limit exceeded. Maximum 3 video generation requests per 10 minutes.' }, { status: 429 } @@ -97,7 +103,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'prompt must be 1000 characters or fewer.' }, { status: 400 }); } - if (typeof aspectRatio !== 'string' || !ALLOWED_ASPECT_RATIOS.includes(aspectRatio as any)) { + if (typeof aspectRatio !== 'string' || !ALLOWED_ASPECT_RATIOS.includes(aspectRatio)) { return NextResponse.json( { error: `aspectRatio must be one of: ${ALLOWED_ASPECT_RATIOS.join(', ')}.` }, { status: 400 } @@ -117,31 +123,112 @@ export async function POST(request: Request) { } try { - const { video } = await experimental_generateVideo({ - model: aiGateway.videoModel(GATEWAY_VIDEO_MODEL), - prompt: prompt.trim(), - aspectRatio: aspectRatio as any, - duration, - abortSignal: AbortSignal.timeout(290_000), - }); - - // experimental_generateVideo returns a GeneratedFile which contains the - // video data and media type. We stream these bytes back to the client. - const videoData = video.uint8Array; - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(videoData); - controller.close(); + const gatewayResponse = await fetch('https://ai-gateway.vercel.sh/v1/video/generations', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', }, + body: JSON.stringify({ + model: 'google/veo-3.1-generate-001', + prompt: prompt.trim(), + aspect_ratio: aspectRatio, + duration_seconds: duration, + }), + signal: AbortSignal.timeout(290_000), }); - return new Response(stream, { + if (!gatewayResponse.ok) { + const errorText = await gatewayResponse.text(); + console.error('[video/generate] Gateway error:', gatewayResponse.status, errorText); + return NextResponse.json( + { error: 'Video generation failed. The model may be unavailable.' }, + { status: gatewayResponse.status } + ); + } + + const data = await gatewayResponse.json(); + + // AI Gateway returns video as a signed URL or base64 depending on the response. + // Validate the shape so we never send a 200 with no usable video payload. + const remoteUrl: string | null = data?.data?.[0]?.url ?? data?.url ?? null; + const inlineBase64: string | null = data?.data?.[0]?.b64_json ?? null; + + if (!remoteUrl && !inlineBase64) { + console.error( + '[video/generate] Unexpected gateway response shape:', + JSON.stringify(data)?.slice(0, 500) + ); + return NextResponse.json( + { error: 'Video generation returned an unexpected response with no video.' }, + { status: 502 } + ); + } + + // Return the raw video bytes as the response body (never base64-in-JSON): a + // realistically-sized Veo clip base64-encoded inside NextResponse.json would + // exceed Vercel's ~4.5 MB serverless response limit and fail with + // FUNCTION_PAYLOAD_TOO_LARGE. The client wraps the bytes in a `blob:` URL, + // which the app's `media-src 'self' blob: data:` CSP permits. + const baseHeaders: Record = { + 'Cache-Control': 'no-store', + 'X-Video-Model': 'google/veo-3.1-generate-001', + }; + + // Case 1: gateway already returned the bytes inline as base64. Decode, then + // stream them back. A buffered `Response(buf)` — like base64-in-JSON — is + // still subject to Vercel's ~4.5 MB response-body limit and would fail with + // FUNCTION_PAYLOAD_TOO_LARGE for large clips; only STREAMED responses bypass + // that limit, so wrap the buffer in a ReadableStream and return that. + if (inlineBase64) { + const buf = Buffer.from(inlineBase64, 'base64'); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buf)); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { + ...baseHeaders, + 'Content-Type': 'video/mp4', + 'Content-Length': String(buf.byteLength), + }, + }); + } + + // Case 2: gateway returned a signed URL. The URL comes from the trusted + // gateway response (NOT client input — no SSRF), so we fetch it server-side + // and STREAM the body straight through to the client. Streaming means we + // never buffer the whole file in memory (no OOM on large clips) and never + // hit the buffered-response size limit. + let videoResp: Response; + try { + videoResp = await fetch(remoteUrl as string, { signal: AbortSignal.timeout(120_000) }); + } catch (fetchErr) { + console.error('[video/generate] Error fetching signed video URL:', fetchErr); + return NextResponse.json( + { error: 'Video was generated but could not be retrieved for playback.' }, + { status: 502 } + ); + } + + if (!videoResp.ok || !videoResp.body) { + console.error('[video/generate] Failed to fetch signed video URL:', videoResp.status); + return NextResponse.json( + { error: 'Video was generated but could not be retrieved for playback.' }, + { status: 502 } + ); + } + + const upstreamLength = videoResp.headers.get('content-length'); + return new Response(videoResp.body, { status: 200, headers: { - 'Cache-Control': 'no-store', - 'Content-Type': video.mediaType || 'video/mp4', - 'Content-Length': String(videoData.byteLength), - 'X-Video-Model': GATEWAY_VIDEO_MODEL, + ...baseHeaders, + 'Content-Type': videoResp.headers.get('content-type') ?? 'video/mp4', + ...(upstreamLength ? { 'Content-Length': upstreamLength } : {}), }, }); } catch (error) { @@ -152,9 +239,6 @@ export async function POST(request: Request) { { status: 504 } ); } - return NextResponse.json( - { error: 'Video generation failed. The model may be unavailable or returned an unexpected response.' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Video generation failed.' }, { status: 500 }); } } diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index 29f8b4ac0..4ff5a3a5b 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -1,30 +1,14 @@ import type { Metadata } from 'next'; import { redirect } from 'next/navigation'; -import { safeCallbackPath } from '@/lib/auth-paths'; export const metadata: Metadata = { title: 'Sign in', - description: 'Sign in to UVAI with Google to open your dashboard.', - alternates: { canonical: '/login' }, + description: + 'UVAI is currently open for use without an account — you go straight to the dashboard.', + alternates: { canonical: '/dashboard' }, robots: { index: false, follow: true }, }; -/** - * Canonical product login entry. Middleware already gates /dashboard; this route - * funnels marketing "Sign in" links into the NextAuth Google flow with a safe - * same-origin callback. - */ -export default async function LoginRedirect({ - searchParams, -}: { - searchParams: Promise<{ callbackUrl?: string | string[] }>; -}) { - const params = await searchParams; - // A repeated ?callbackUrl= yields an array at runtime — take the first value. - const rawParam = params?.callbackUrl; - const raw = Array.isArray(rawParam) ? rawParam[0] : rawParam; - // Reuse the shared sanitizer so /login enforces the same open-redirect - // protection (backslash + scheme tricks) as the proxy's callback handling. - const callback = safeCallbackPath(raw ?? '/dashboard'); - redirect(`/api/auth/signin?callbackUrl=${encodeURIComponent(callback)}`); +export default function LoginRedirect() { + redirect('/dashboard'); } diff --git a/apps/web/src/components/InteractiveTranscript.tsx b/apps/web/src/components/InteractiveTranscript.tsx index 21f79d91b..573378df4 100644 --- a/apps/web/src/components/InteractiveTranscript.tsx +++ b/apps/web/src/components/InteractiveTranscript.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react'; +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { clsx } from 'clsx'; /* ═══════════════════════════════════════════ @@ -53,7 +53,7 @@ function formatTimestamp(seconds: number): string { * @param isPast - Whether this segment ends before the current playback position. * @param onSeek - Called with the segment start time when the row is activated. */ -const SegmentRow = memo(function SegmentRow({ +function SegmentRow({ segment, isActive, isPast, @@ -138,7 +138,7 @@ const SegmentRow = memo(function SegmentRow({

); -}); +} /** * Renders an interactive transcript with speaker filtering, search, and playback progress. diff --git a/apps/web/src/components/dashboard/VideoCanvasStage.tsx b/apps/web/src/components/dashboard/VideoCanvasStage.tsx index d1474dcca..beef4d9da 100644 --- a/apps/web/src/components/dashboard/VideoCanvasStage.tsx +++ b/apps/web/src/components/dashboard/VideoCanvasStage.tsx @@ -163,7 +163,6 @@ export default function VideoCanvasStage({ aria-valuemax={Math.floor(duration) || 0} aria-valuenow={Math.floor(currentTime) || 0} aria-valuetext={`${formatSeconds(currentTime)} of ${formatSeconds(duration)}`} - aria-keyshortcuts="ArrowLeft ArrowRight Home End" onClick={(e) => seekFromClientX(e.clientX)} onKeyDown={onTrackKeyDown} className={`group relative flex-1 h-9 flex items-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-[#6af2de] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0e0e13] ${seekable ? 'cursor-pointer' : 'cursor-default'}`} diff --git a/apps/web/src/components/dashboard/panels.tsx b/apps/web/src/components/dashboard/panels.tsx index 6276364f7..f2c12cc77 100644 --- a/apps/web/src/components/dashboard/panels.tsx +++ b/apps/web/src/components/dashboard/panels.tsx @@ -290,11 +290,7 @@ export function SearchPanel({ }} className="flex gap-2" > - youtube.com/watch?v= - auJzb1D-fag + dQw4w9WgXcQ { 'add_to_knowledge_base', 'create_workflow_task', 'dispatch_agent', - 'dispatch_subagents', - 'get_agent_session_logs', 'save_resource', 'schedule_followup', ].sort(), @@ -118,88 +116,6 @@ describe('action tool registry', () => { expect(body2.tags).toEqual(['a', 'b']); }); - it('dispatch_subagents reports honestly when no backend is configured', async () => { - const tool = getTool('dispatch_subagents')!; - const res = await tool.execute( - { parentTask: 'ship it', subagents: [{ agentType: 'researcher', instruction: 'y' }] }, - NO_BACKEND, - ); - expect(res.isError).toBe(true); - expect(res.summary).toMatch(/no backend configured/i); - }); - - it('dispatch_subagents dispatches one call per subagent, pairing agentType with its own instruction', async () => { - // Fresh Response per call (bodies are single-use) and one call per subagent - // proves there is no cartesian fan-out mispairing. - const fetchImpl = vi - .fn() - .mockImplementation(async () => new Response(JSON.stringify({ data: { executions: [{}] } }))); - - const tool = getTool('dispatch_subagents')!; - const res = await tool.execute( - { - parentTask: 'ship the feature', - subagents: [ - { agentType: 'code_generator', instruction: 'write the code' }, - { agentType: 'researcher', instruction: 'research the API' }, - ], - }, - { backendBaseUrl: 'http://backend', fetchImpl, jobId: 'job1' }, - ); - - expect(fetchImpl).toHaveBeenCalledTimes(2); - const bodies = fetchImpl.mock.calls.map( - (c) => JSON.parse((c[1] as RequestInit).body as string), - ); - expect(fetchImpl.mock.calls[0][0]).toBe('http://backend/api/v1/agents/dispatch'); - expect(bodies[0].agent_types).toEqual(['code_generator']); - expect(bodies[0].events).toHaveLength(1); - expect(bodies[0].events[0].title).toBe('write the code'); - expect(bodies[1].agent_types).toEqual(['researcher']); - expect(bodies[1].events[0].title).toBe('research the API'); - expect(res.isError).toBeFalsy(); - }); - - it('dispatch_subagents rejects malformed subagent entries before any dispatch', async () => { - const fetchImpl = vi.fn(); - const tool = getTool('dispatch_subagents')!; - const res = await tool.execute( - { parentTask: 'x', subagents: [{ agentType: 'code_generator' }] }, // missing instruction - { backendBaseUrl: 'http://backend', fetchImpl, jobId: 'job1' }, - ); - expect(res.isError).toBe(true); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it('get_agent_session_logs GETs the sessions endpoint with agent_type and limit filters', async () => { - const fetchImpl = vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify({ data: { sessions: [{ agent_type: 'researcher' }] } })), - ); - const tool = getTool('get_agent_session_logs')!; - const res = await tool.execute( - { agentType: 'researcher', limit: 5 }, - { backendBaseUrl: 'http://backend', fetchImpl }, - ); - - expect(fetchImpl).toHaveBeenCalledOnce(); - const url = fetchImpl.mock.calls[0][0] as string; - expect(url).toContain('/api/v1/agents/sessions'); - expect(url).toContain('agent_type=researcher'); - expect(url).toContain('limit=5'); - expect(res.isError).toBeFalsy(); - expect(res.data).toMatchObject({ count: 1 }); - }); - - it('get_agent_session_logs surfaces a non-ok backend response as an error', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response('nope', { status: 503 })); - const tool = getTool('get_agent_session_logs')!; - const res = await tool.execute({}, { backendBaseUrl: 'http://backend', fetchImpl }); - expect(res.isError).toBe(true); - expect(res.summary).toContain('503'); - }); - it('adapts tools to OpenAI function-tool format', () => { const openai = toOpenAITools(); expect(openai).toHaveLength(ACTION_TOOLS.length); diff --git a/apps/web/src/lib/__tests__/auth-paths.test.ts b/apps/web/src/lib/__tests__/auth-paths.test.ts deleted file mode 100644 index fecb47ca6..000000000 --- a/apps/web/src/lib/__tests__/auth-paths.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - isPublicApiPath, - isProtectedPagePath, - needsAuthentication, - safeCallbackPath, - shouldSkipRateLimit, -} from '@/lib/auth-paths'; - -describe('auth path policy', () => { - it('keeps NextAuth and Stripe webhook public', () => { - expect(isPublicApiPath('/api/auth')).toBe(true); - expect(isPublicApiPath('/api/auth/signin/google')).toBe(true); - expect(isPublicApiPath('/api/auth/callback/google')).toBe(true); - expect(isPublicApiPath('/api/billing/webhook')).toBe(true); - expect(isPublicApiPath('/api/billing/status')).toBe(true); - expect(isPublicApiPath('/api/billing/checkout')).toBe(true); - }); - - it('keeps checkout-lifecycle billing routes reachable without a NextAuth session', () => { - // activate identifies the payer from the Stripe checkout sessionId and renew - // from the signed billing cookie — neither has a NextAuth session at that - // point, so middleware must not 401 them. - expect(isPublicApiPath('/api/billing/activate')).toBe(true); - expect(isPublicApiPath('/api/billing/renew')).toBe(true); - expect(needsAuthentication('/api/billing/activate')).toBe(false); - expect(needsAuthentication('/api/billing/renew')).toBe(false); - }); - - it('still gates non-allowlisted billing routes', () => { - // A sibling billing route with no explicit exemption stays protected — - // guards against prefix-match over-exposure. - expect(isPublicApiPath('/api/billing/manage')).toBe(false); - expect(needsAuthentication('/api/billing/manage')).toBe(true); - }); - - it('requires auth for product APIs and dashboard pages', () => { - expect(needsAuthentication('/api/chat')).toBe(true); - expect(needsAuthentication('/api/pipeline')).toBe(true); - expect(needsAuthentication('/api/video')).toBe(true); - expect(needsAuthentication('/dashboard')).toBe(true); - expect(needsAuthentication('/dashboard/agents')).toBe(true); - expect(isProtectedPagePath('/dashboard/agents')).toBe(true); - }); - - it('does not gate marketing pages', () => { - expect(needsAuthentication('/')).toBe(false); - expect(needsAuthentication('/pricing')).toBe(false); - expect(needsAuthentication('/features')).toBe(false); - }); - - it('sanitizes callback paths against open redirects', () => { - expect(safeCallbackPath('/dashboard')).toBe('/dashboard'); - expect(safeCallbackPath('/dashboard', '?tab=agents')).toBe('/dashboard?tab=agents'); - expect(safeCallbackPath('//evil.com')).toBe('/dashboard'); - expect(safeCallbackPath('https://evil.com')).toBe('/dashboard'); - expect(safeCallbackPath('/\\evil.com')).toBe('/dashboard'); - }); - - it('skips rate limits for the auth handshake', () => { - expect(shouldSkipRateLimit('/api/auth/csrf')).toBe(true); - expect(shouldSkipRateLimit('/api/auth/callback/google')).toBe(true); - expect(shouldSkipRateLimit('/api/chat')).toBe(false); - }); -}); diff --git a/apps/web/src/lib/__tests__/dashboard-search-accessibility.test.ts b/apps/web/src/lib/__tests__/dashboard-search-accessibility.test.ts deleted file mode 100644 index 2f4c19357..000000000 --- a/apps/web/src/lib/__tests__/dashboard-search-accessibility.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -const webSrc = join(dirname(fileURLToPath(import.meta.url)), '../..'); - -function readSource(relativePath: string) { - return readFileSync(join(webSrc, relativePath), 'utf8'); -} - -describe('dashboard search accessibility', () => { - it('keeps a programmatic label on the search input without overriding the Go button name', () => { - const source = readSource('components/dashboard/panels.tsx'); - const searchForm = source.match( - /[\s\S]*?{searchLoading \? '…' : 'Go'}[\s\S]*?<\/form>/, - )?.[0]; - - expect(searchForm).toBeDefined(); - expect(searchForm).toContain('