diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ed981d9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# Keep the Docker build context minimal — the Dockerfile only needs +# package*.json, tsconfig.json, src/, and sources.example.json. Excluding the +# rest speeds builds and keeps local secrets/artifacts out of image layers. +node_modules +dist +coverage +.git +.github +.gitignore +.gitleaks.toml +.editorconfig +.prettierrc +.prettierignore +eslint.config.js +vitest.config.ts +chart +docs +gitops +*.md +.env* +platform.yaml +renovate.json +Taskfile.yaml +src/**/*.test.ts diff --git a/.env.example b/.env.example index 855ed84..305c68f 100644 --- a/.env.example +++ b/.env.example @@ -20,9 +20,11 @@ AWS_REGION=us-east-1 BEDROCK_LLM_MODEL=us.anthropic.claude-sonnet-4-20250514-v1:0 BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0 -# ─── Direct API keys (only if using anthropic/openai providers) ─── +# ─── Direct API keys + models (only if using anthropic/openai providers) ─── # ANTHROPIC_API_KEY= # OPENAI_API_KEY= +# ANTHROPIC_LLM_MODEL=claude-sonnet-4-6 # direct-Anthropic model (default shown) +# OPENAI_LLM_MODEL=gpt-4o # direct-OpenAI model (default shown) # ─── Embeddings ─── EMBEDDING_DIMENSIONS=1024 # 1024 for Titan v2, 1536 for OpenAI @@ -37,6 +39,9 @@ VECTOR_PROVIDER=memory # memory (data lost on restart) | pgvector (d # PGDATABASE=competitive_intelligence # PGUSER= # PGPASSWORD= +# Path to a CA bundle (e.g. the Amazon RDS global CA) for verifying the pgvector +# TLS connection. Unset → Node's built-in trust store (sufficient for RDS/Aurora). +# PG_CA_PATH=/etc/ssl/rds/rds-combined-ca-bundle.pem # ─── Crawler ─── CRAWL_INTERVAL_MINUTES=60 @@ -58,6 +63,7 @@ SIGNIFICANCE_THRESHOLD=0.3 # 0–1, semantic change required to trigger a # Leave unset locally to fall back to the no-op API. # OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability.svc.cluster.local:4318 # OTEL_RESOURCE_ATTRIBUTES=service.name=competitive-intelligence,deployment.environment=dev,agents.tenant=protohype,agents.platform=competitive-intelligence +# OTEL_SERVICE_NAME=competitive-intelligence # service.name; overridden by service.name in OTEL_RESOURCE_ATTRIBUTES if set there # OTEL_SDK_DISABLED=true # set true to disable OTel entirely (e.g. local dev) # ─── Server ─── diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6b811c..616c2b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,8 +48,9 @@ jobs: - name: Test # OTEL_SDK_DISABLED short-circuits the OTel SDK so the test run never # tries to reach the cluster collector. The metrics/tracing API - # degrades to a no-op without a registered provider. - run: npm test + # degrades to a no-op without a registered provider. --coverage enforces + # the thresholds in vitest.config.ts so coverage can't silently regress. + run: npm run test:coverage env: OTEL_SDK_DISABLED: "true" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 372d09f..20afd82 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -37,6 +37,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # Report step: ALWAYS produce + upload a clean SARIF (exit-code 0) so + # code scanning records a SUCCESSFUL analysis. Coupling the SARIF run with + # the build gate (exit-code 1) makes Trivy mark the SARIF invocation + # unsuccessful whenever a finding exists, which GitHub surfaces as + # "Trivy is reporting errors" / a code-scanning configuration error. - uses: aquasecurity/trivy-action@master with: scan-type: config @@ -48,13 +53,8 @@ jobs: # them rather than surfacing real findings. scan-ref: Dockerfile severity: HIGH,CRITICAL - # The gate is HIGH/CRITICAL only. Without this, trivy-action builds - # the SARIF with all severities and exits non-zero on any of them — - # so MEDIUM/LOW lint (e.g. dockerfile best-practice notes) would fail - # the build. This keeps the gate to HIGH/CRITICAL while still - # surfacing lower-severity findings in the SARIF for code-scanning. limit-severities-for-sarif: true - exit-code: "1" + exit-code: "0" format: sarif output: trivy-config.sarif - uses: github/codeql-action/upload-sarif@v3 @@ -62,12 +62,25 @@ jobs: with: sarif_file: trivy-config.sarif category: trivy-config + # Gate step: fail the build on HIGH/CRITICAL, reusing the DB the report + # step already downloaded (skip-db-update). Separate from the SARIF upload + # so a finding fails CI without poisoning the code-scanning analysis. + - name: gate on HIGH/CRITICAL + uses: aquasecurity/trivy-action@master + with: + scan-type: config + scan-ref: Dockerfile + severity: HIGH,CRITICAL + format: table + exit-code: "1" + skip-db-update: true trivy-fs: name: trivy (filesystem vuln scan — npm deps) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # Report step: always upload a clean SARIF (exit-code 0) — see trivy-config. - uses: aquasecurity/trivy-action@master with: scan-type: fs @@ -77,10 +90,8 @@ jobs: # to actionable (patch-available) CVEs. scanners: vuln severity: HIGH,CRITICAL - # Gate on HIGH/CRITICAL only (see trivy-config note); lower-severity - # CVEs still upload to code-scanning as warnings. limit-severities-for-sarif: true - exit-code: "1" + exit-code: "0" ignore-unfixed: true format: sarif output: trivy-fs.sarif @@ -90,3 +101,16 @@ jobs: with: sarif_file: trivy-fs.sarif category: trivy-fs + # Gate step: fail the build on HIGH/CRITICAL, reusing the cached DB. + - name: gate on HIGH/CRITICAL + uses: aquasecurity/trivy-action@master + with: + scan-type: fs + scan-ref: . + scanners: vuln + severity: HIGH,CRITICAL + ignore-unfixed: true + format: table + exit-code: "1" + skip-dirs: "node_modules" + skip-db-update: true diff --git a/.gitignore b/.gitignore index 4274b51..dd6ef19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +coverage/ .env *.log .DS_Store diff --git a/AGENTS.md b/AGENTS.md index d0b0f92..992b865 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,10 +128,10 @@ The vector store is the durability seam. `VectorStore` (`src/providers/vectors.t - **Provider registry, not inline construction.** LLM / embeddings / vectors are each a `createRegistry(kind)` returning typed `{ register, get, has, names }`. Pick the implementation by config; `src/index.ts` is the only place real clients are built. Swapping a backend is a one-file change to the bootstrap. - **Bedrock-default LLM.** Bedrock (Converse for the LLM, Titan for embeddings) is the default and runs on the AWS credential chain — IRSA on the cluster, no keys. Anthropic/OpenAI are alternates that only register when their key is present. - **Prompt caching.** The analysis system prompt is identical on every diff, so the Converse request marks a `cachePoint` after the system block. Cache hits are emitted as a metric — see `ARCHITECTURE.md` § Prompt caching. -- **Circuit breakers on every external call** — per-host for the crawler's HTTP fetcher, per-provider for LLM + embeddings. Threshold-based, no library. +- **Circuit breakers on every external call** — per-host for the crawler's HTTP fetcher, per-provider for LLM + embeddings, and around the Slack alert sink. Threshold-based, no library. - **Single-writer scheduler + crawl mutex.** `replicaCount: 1`. The scheduler runs one global crawl over all sources on an interval; an in-process mutex prevents the scheduler and a `/competitive-intelligence crawl` from overlapping. Scaling horizontally without leader election would double-crawl and race the differ — don't. - **SSRF-guarded crawling.** Every outbound crawl URL passes `guardUrl` (`src/crawler/url-guard.ts`) — rejects loopback, RFC1918, link-local, and cloud-metadata addresses before the fetch. -- TypeScript strict, ESM NodeNext, Node ≥ 24. Zod at every boundary (config, sources, log level). Structured JSON logging to stderr via Pino; stdout is reserved for CLI output. Explicit timeouts on every external call. +- TypeScript strict, ESM NodeNext, Node ≥ 24. Zod at every boundary (config, sources, log level, LLM analysis output). Structured JSON logging to stderr via a hand-rolled logger (`src/logger.ts`); stdout is reserved for CLI output. Explicit timeouts on every external call (Bedrock/Anthropic/OpenAI, pgvector, Slack). ## Pointers diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 28839d2..c17b146 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -17,7 +17,7 @@ The system organizes around eight contexts. Cross-boundary services go through a | **scheduler** | `src/scheduler/` | `createScheduler` is a `setInterval`-based job runner. Runs one global crawl over all sources at `CRAWL_INTERVAL_MINUTES`. The crawl mutex (in `src/index.ts`) prevents the scheduler and a slash-command crawl from overlapping | | **resilience** | `src/resilience/` | `CircuitBreaker` — a threshold-based breaker used per-host by the fetcher and per-provider by the LLM/embeddings providers. Trip → fail fast → half-open probe → recover | -Cross-cutting: `src/config.ts` (Zod env validation, fail-fast at boot), `src/logger.ts` (Pino JSON to stderr with OTel `trace_id`/`span_id` correlation), `src/metrics.ts` (OTel timing/counter surface), `src/cli.ts` (one-off `crawl`/`query`), `src/index.ts` (bootstrap + the `/health`+`/readyz` HTTP server). +Cross-cutting: `src/config.ts` (Zod env validation, fail-fast at boot), `src/logger.ts` (hand-rolled structured JSON logging to stderr), `src/metrics.ts` (OTel timing/counter/gauge surface), `src/cli.ts` (one-off `crawl`/`query`) with `src/display.ts` (ANSI CLI presentation), `src/index.ts` (bootstrap + the `/health`+`/readyz` HTTP server). OTel is initialized by the Dockerfile's auto-instrumentations `--require` preload (env-driven config), not by app code. ## Key decisions @@ -47,7 +47,7 @@ The analysis system prompt (`ANALYSIS_SYSTEM` in `src/intel/analysis.ts`) is ide The analysis system prompt is cached via a Converse `cachePoint` marker placed after the system block (and after any stable context prefix). Because that prefix is byte-identical across every diff analyzed within the cache TTL, the second and subsequent analyses in a crawl batch read the prompt from cache rather than re-billing it as input tokens. -Cache effectiveness is **measured, not assumed**. The provider records Bedrock token usage split by kind, emitted as `bedrock.tokens{kind}` with `kind ∈ {input, output, cache_read, cache_write}`. The cache-hit ratio is `cache_read / (cache_read + cache_write)` over a window — high on a warm radar (the system prompt is reused across every source in a crawl) and zero only on the first analysis after a cache expiry. The Grafana dashboard plots the ratio and the token split; the LLM policy requires both the `cachePoint` marker and a measured ratio, which the metric satisfies. +Cache effectiveness is **measured, not assumed**. `BedrockLlmProvider.chat` records token usage from every Converse response as four distinct counters — `bedrock.input_tokens`, `bedrock.output_tokens`, `bedrock.cache_read_tokens`, `bedrock.cache_write_tokens` (exported to Mimir as `competitive_intelligence_bedrock_*_tokens_total`). The cache-hit ratio is `cache_read / (cache_read + cache_write)` over a window — high on a warm radar (the system prompt is reused across every source in a crawl) and zero only on the first analysis after a cache expiry. The Grafana dashboard plots the ratio and the token split; the LLM policy requires both the `cachePoint` marker and a measured ratio, which the metric satisfies. ## Data flow: a single crawl @@ -60,7 +60,8 @@ Cache effectiveness is **measured, not assumed**. The provider records Bedrock t b. embed chunks (Bedrock Titan, default) c. semantic diff: each chunk vs best same-source match (cosine < 0.85 → new) → cold-start guard: source count()==0 → baseline (ingest, suppress alerts) - d. replace history: deleteByMetadata(sourceId) → upsert new chunks + d. replace history: upsert new chunks → prune stale (deleteByMetadata, keeping new ids) + — ordered so a mid-write failure can't wipe a source's history 5. alertEngine.processDiffs(diffs): per diff with changeScore ≥ SIGNIFICANCE_THRESHOLD → a. LLM analysis (Bedrock Converse, cached system prompt) → summary + significance + signals b. format Block Kit → dispatch to the Slack alert sink (#competitive-intel) diff --git a/CLAUDE.md b/CLAUDE.md index 2adc7b5..3e0e066 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Core insight: semantic diffing via embedding cosine similarity, not text compari ## Architecture -- **src/providers/** — Self-registering provider registry. LLM (`llm.ts`: Bedrock/Anthropic/OpenAI), embeddings (`embeddings.ts`: Bedrock Titan/OpenAI), vector store (`vectors.ts`: `MemoryVectorStore` for dev/tests + `PgVectorStore` for durable production, both behind the `VectorStore` interface). All via `createRegistry()`. The Bedrock LLM marks a Converse `cachePoint` after the static analysis system prompt — cache hits are emitted as `bedrock.tokens{kind:cache_read/cache_write}`. +- **src/providers/** — Self-registering provider registry. LLM (`llm.ts`: Bedrock/Anthropic/OpenAI), embeddings (`embeddings.ts`: Bedrock Titan/OpenAI), vector store (`vectors.ts`: `MemoryVectorStore` for dev/tests + `PgVectorStore` for durable production, both behind the `VectorStore` interface). All via `createRegistry()`. The Bedrock LLM marks a Converse `cachePoint` after the static analysis system prompt — token usage is emitted per kind as `bedrock.{input,output,cache_read,cache_write}_tokens` so cache effectiveness is measurable. Every external call carries an explicit timeout (Bedrock via `requestHandler` + an `AbortSignal.timeout` deadline, Anthropic/OpenAI via the SDK `timeout` option). - **src/crawler/** — HTTP fetcher with per-host circuit breakers, HTML→text via cheerio scoped by `selectors`. SSRF-guarded (`url-guard.ts`) — every outbound URL rejects loopback/RFC1918/link-local/metadata addresses before the fetch. Sequential crawling. `sources.ts` Zod-validates `sources.json` on load. - **src/pipeline/** — Recursive text chunker with overlap → embed → semantic diff against stored vectors → `deleteByMetadata` old chunks → upsert new. Holds the cold-start baseline guard. - **src/intel/** — Query facade: embed question → vector search → LLM-generated answer with context. `analysis.ts` holds the LLM change analysis (significance + signal extraction) and the cached analysis/query system prompts. @@ -32,8 +32,9 @@ Core insight: semantic diffing via embedding cosine similarity, not text compari - **src/slack/** — `@slack/bolt` app. @mention + DM query handlers (`handlers.ts`), `/competitive-intelligence query|crawl|status` slash command (`commands.ts`). Socket Mode when `SLACK_APP_TOKEN` is set, HTTP mode otherwise. - **src/scheduler/** — `setInterval`-based job runner. One global crawl over all sources at a configurable interval. The crawl mutex (in `index.ts`) prevents the scheduler and a slash-command crawl from overlapping. - **src/index.ts** — Bootstrap. Wires config → providers → sources → crawl loop → intel/alert engines → Slack bot → scheduler. Runs a `node:http` server for `/health` (liveness) + `/readyz` (readiness — vector store reachable, Slack connected in Socket Mode) on `PORT`, independent of Slack transport. Runs an initial crawl on boot, then on interval. Graceful shutdown on SIGINT/SIGTERM. -- **src/cli.ts** — One-off `crawl` and `query` commands for use without Slack. -- **src/metrics.ts** — OTel metrics surface (`timing` → histogram, `counter` → monotonic counter). Exported OTLP by the auto-instrumentation runtime to the cluster OTel Collector. Degrades to a no-op when no provider is registered (tests). +- **src/cli.ts** — One-off `crawl` and `query` commands for use without Slack. Reuses `crawlAll` (per-source progress via its `onResult` callback) and renders output through **src/display.ts** (ANSI CLI presentation layer). +- **OTel init** — telemetry is started once, by the Dockerfile's `--require @opentelemetry/auto-instrumentations-node/register` preload (it must load before any instrumented module is imported, which app code cannot guarantee). All export config is env-driven (`OTEL_*` in the chart); there is no programmatic SDK in the app. `OTEL_SDK_DISABLED=true` short-circuits it for tests/CI/local. +- **src/metrics.ts** — OTel metrics surface (`timing` → ms histogram, `distribution` → unitless histogram, `counter` → monotonic counter, plus an observable `circuit_breaker.open` gauge). Instrument names map to the `competitive_intelligence_*` series the Grafana dashboard + PrometheusRule query. Exported OTLP to the cluster OTel Collector. Degrades to a no-op when no provider is registered (tests). ## Commands @@ -63,17 +64,21 @@ All config via env vars, validated by Zod in `src/config.ts`. See `.env.example` - `LLM_PROVIDER` — bedrock (default), anthropic, or openai - `EMBEDDING_PROVIDER` — bedrock (default) or openai - `AWS_REGION` — for Bedrock. Uses the AWS credential chain → IRSA on the cluster, no API keys -- `BEDROCK_LLM_MODEL` / `BEDROCK_EMBEDDING_MODEL` — model IDs (LLM defaults to a current cross-region Claude Sonnet inference profile; embeddings to Titan Embed v2) +- `BEDROCK_LLM_MODEL` / `BEDROCK_EMBEDDING_MODEL` — model IDs (LLM defaults to a cross-region Claude Sonnet inference profile; embeddings to Titan Embed v2) - `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` — only when using those providers directly +- `ANTHROPIC_LLM_MODEL` / `OPENAI_LLM_MODEL` — direct-API model IDs (defaults `claude-sonnet-4-6` / `gpt-4o`) +- `EMBEDDING_MODEL` / `EMBEDDING_DIMENSIONS` — OpenAI embedding model + vector size (default 1024; 1024 for Titan v2, 1536 for OpenAI) - `VECTOR_PROVIDER` — `pgvector` in cluster (durable, restart-safe), `memory` for local dev/tests - `DATABASE_URL` / `PG*` — Postgres connection for pgvector; in cluster these come from `competitive-intelligence//db-credentials` +- `PG_CA_PATH` — optional CA bundle for verifying the pgvector TLS connection; unset → Node's built-in trust store - `SIGNIFICANCE_THRESHOLD` — 0–1, minimum change score to trigger an alert (default 0.3) - `CRAWL_INTERVAL_MINUTES` — default 60 - `CRAWL_TIMEOUT_MS` — per-page fetch timeout (default 30000) - `SLACK_BOT_TOKEN` / `SLACK_SIGNING_SECRET` / `SLACK_APP_TOKEN` — Slack; absent → CLI-only - `SLACK_ALERT_CHANNEL` — alert channel (default `#competitive-intel`) - `USER_AGENT` — crawl request User-Agent (default `competitive-intelligence/0.1.0`) -- `PORT` — HTTP health-server port (default 3000) +- `PORT` — HTTP health-server port (default 3000); in Slack HTTP mode the Bolt receiver binds `PORT + 1` +- `NODE_ENV` — development (default), production, or test - `LOG_LEVEL` — debug, info (default), warn, error. Zod-validated. Bedrock needs model access to Claude Sonnet and Titan Embed v2 in the deployment region. Sources are defined in `sources.json` (see `sources.example.json`), Zod-validated on load. diff --git a/Dockerfile b/Dockerfile index 038d830..90621b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,10 +18,11 @@ RUN addgroup -g 1001 -S app && adduser -u 1001 -S app -G app COPY package.json package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force -# Compiled output + the example sources manifest (the bundled crawl-source -# catalog the app reads when no sources.json is mounted). +# Compiled output + the starter crawl-source catalog. The app reads +# `sources.json` (src/index.ts, src/cli.ts), so ship the example as that path; +# mount a curated sources.json over it per-env to monitor a real source list. COPY --from=builder /app/dist ./dist -COPY sources.example.json ./ +COPY sources.example.json ./sources.json USER app diff --git a/README.md b/README.md index ecce30f..53d3ab7 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ task ci # build + lint + typecheck + format:check + test + helm lint/template ## Bedrock prerequisites -Bedrock is the default for both LLM and embeddings and runs on the AWS credential chain — no API keys. On the cluster that chain resolves to IRSA; locally it resolves to your `~/.aws` credentials or SSO. Confirm `aws sts get-caller-identity` works, and enable model access for `anthropic.claude-sonnet-4-6` (or your configured `BEDROCK_LLM_MODEL`) and `amazon.titan-embed-text-v2:0` in the [Bedrock console](https://console.aws.amazon.com/bedrock/home#/modelaccess) for your region. To use a direct API provider instead, set `LLM_PROVIDER` / `EMBEDDING_PROVIDER` and the matching key. +Bedrock is the default for both LLM and embeddings and runs on the AWS credential chain — no API keys. On the cluster that chain resolves to IRSA; locally it resolves to your `~/.aws` credentials or SSO. Confirm `aws sts get-caller-identity` works, and enable model access for the configured `BEDROCK_LLM_MODEL` (default `us.anthropic.claude-sonnet-4-20250514-v1:0`) and `amazon.titan-embed-text-v2:0` in the [Bedrock console](https://console.aws.amazon.com/bedrock/home#/modelaccess) for your region. To use a direct API provider instead, set `LLM_PROVIDER` / `EMBEDDING_PROVIDER` and the matching key. ## Sources diff --git a/Taskfile.yaml b/Taskfile.yaml index f346ff6..59ef231 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -57,9 +57,9 @@ tasks: - npm run format:check test: - desc: Run the test suite + desc: Run the test suite with coverage thresholds cmds: - - npm test + - npm run test:coverage # === Helm === diff --git a/chart/README.md b/chart/README.md index 00c2c13..5638d4c 100644 --- a/chart/README.md +++ b/chart/README.md @@ -6,18 +6,29 @@ The workload is a single long-lived worker: a scheduler that runs one global cra ## Files -- `Chart.yaml` — chart metadata +- `Chart.yaml` — chart metadata + the `tenant-chart-base` dependency (see Dependencies) - `values.yaml` — base values (all environments) - `values-dev.yaml` / `values-staging.yaml` / `values-production.yaml` — per-env deltas +- `charts/tenant-chart-base/` — vendored library subchart (see Dependencies) +- `dashboards/competitive-intelligence.json` — the Grafana dashboard JSON loaded by `grafana-dashboard.yaml` - `templates/` - `deployment.yaml` — the worker pod. Non-root, `readOnlyRootFilesystem` with a `/tmp` emptyDir, env from `values.env` + `tenantInfra.pg*`, secrets via `envFrom: secretRef`, liveness `/health` + readiness `/readyz` on the health port, `checksum/external-secret` pod-roll annotation. `replicaCount: 1` with a `Recreate` strategy — single-writer scheduler + crawl mutex; never run two at once. - `service.yaml` — ClusterIP on the health port (default 3000) - - `serviceaccount.yaml` — IRSA annotation fed by `aws.platformRoleArn` (per-env), pointing at the landing-zone-owned `competitive-intelligence-platform` IRSA role. No inline IAM. + - `serviceaccount.yaml` — thin `tenant-chart-base.serviceaccount` include; IRSA annotation fed by `aws.platformRoleArn` (per-env), pointing at the landing-zone-owned `competitive-intelligence-platform` IRSA role. No inline IAM. - `externalsecret.yaml` — pulls Slack tokens + optional provider keys from `competitive-intelligence//app-secrets` and `PGUSER`/`PGPASSWORD` from `competitive-intelligence//db-credentials` - - `networkpolicy.yaml` — default-deny + egress allow-list (DNS, HTTPS to the open internet minus IMDS, Postgres to the VPC CIDR); ingress is same-namespace probes only - - `prometheusrule.yaml` — alerts: crawl-failure spike, circuit-breaker open, alert-send failure, pgvector unreachable - - `grafana-dashboard.yaml` — ConfigMap (labeled `grafana_dashboard: "1"`) loading the dashboard from `dashboards/competitive-intelligence.json` - - `_helpers.tpl` — name/label helpers + - `networkpolicy.yaml` — thin `tenant-chart-base.networkpolicy` include; default-deny + egress allow-list (DNS, HTTPS to the open internet minus IMDS, Postgres to the VPC CIDR); ingress is same-namespace probes only + - `prometheusrule.yaml` — alerts (crawl-failure spike, circuit-breaker open, alert-send failure, pgvector unreachable); uses the base chart's fullname/labels helpers + - `grafana-dashboard.yaml` — thin `tenant-chart-base` include rendering a ConfigMap (labeled `grafana_dashboard: "1"`) from `dashboards/competitive-intelligence.json` + +## Dependencies + +The chart vendors `charts/tenant-chart-base` — a `type: library` chart from +`nanohype/templates/tenant-chart-base`, declared in `Chart.yaml` as a +`file://charts/tenant-chart-base` dependency so `helm` works offline with no +fetch. It provides the shared named templates that render the ServiceAccount, +NetworkPolicy, PrometheusRule, and Grafana dashboard, plus the name/label +helpers (`tenant-chart-base.fullname`, `tenant-chart-base.labels`). There is no +local `_helpers.tpl`; those helpers live in the base subchart. ## Relationship to companion files @@ -47,7 +58,7 @@ Drop that into `chart/values-production.yaml` under `aws.platformRoleArn`. ArgoC ## LLM -Bedrock is the default LLM provider (`LLM_PROVIDER=bedrock`), authenticated via IRSA. The model is pinned in `values.yaml` (`BEDROCK_LLM_MODEL: anthropic.claude-sonnet-4-6`, the llm-policy Sonnet default tier) — verify model availability in the target region before promoting, since cross-region inference profiles differ. Anthropic and OpenAI remain pluggable alternates; their keys arrive through the ExternalSecret only when those providers are selected. +Bedrock is the default LLM provider (`LLM_PROVIDER=bedrock`), authenticated via IRSA. The model is pinned in `values.yaml` (`BEDROCK_LLM_MODEL: us.anthropic.claude-sonnet-4-20250514-v1:0`, a cross-region Sonnet inference profile — Converse requires the `us.anthropic.*-v1:0` profile form, not a bare alias) — verify the profile exists in the target region before promoting, since cross-region inference profiles differ. Anthropic and OpenAI remain pluggable alternates; their keys arrive through the ExternalSecret only when those providers are selected. ## Render locally @@ -70,3 +81,13 @@ This chart owns the app's k8s surface. The cloud substrate and cluster addons si - `prometheusrule.yaml` — crawl-failure, circuit-breaker-open, alert-send-failure, and pgvector-unreachable alerts. Alertmanager (eks-gitops) routes them. - `grafana-dashboard.yaml` — a ConfigMap labeled `grafana_dashboard: "1"` loading the dashboard from `chart/dashboards/competitive-intelligence.json`; the Grafana sidecar picks it up automatically. + +> **Collector requirement (eks-gitops).** The rules and dashboard query the +> `competitive_intelligence_*` series with a `deployment_environment` label. That +> series name + label only materialize if the cluster OTel Collector's Prometheus +> pipeline applies a `competitive_intelligence` namespace and enables +> `resource_to_telemetry_conversion` (promoting the `service.name` / +> `deployment.environment` resource attributes — set here via `OTEL_RESOURCE_ATTRIBUTES` +> — to metric labels). If panels read empty, check that collector config first. +> The pod also needs egress to the collector on tcp/4318 (`networkPolicy.egress`, +> already included here). diff --git a/chart/dashboards/competitive-intelligence.json b/chart/dashboards/competitive-intelligence.json index 297700e..40f3782 100644 --- a/chart/dashboards/competitive-intelligence.json +++ b/chart/dashboards/competitive-intelligence.json @@ -157,7 +157,7 @@ "gridPos": {"x": 0, "y": 24, "w": 12, "h": 8}, "targets": [ { - "expr": "sum(rate(competitive_intelligence_bedrock_cache_read_tokens_total{deployment_environment=\"$environment\"}[5m])) / clamp_min(sum(rate(competitive_intelligence_bedrock_input_tokens_total{deployment_environment=\"$environment\"}[5m])), 1)", + "expr": "sum(rate(competitive_intelligence_bedrock_cache_read_tokens_total{deployment_environment=\"$environment\"}[5m])) / clamp_min(sum(rate(competitive_intelligence_bedrock_cache_read_tokens_total{deployment_environment=\"$environment\"}[5m])) + sum(rate(competitive_intelligence_bedrock_cache_write_tokens_total{deployment_environment=\"$environment\"}[5m])), 1)", "legendFormat": "cache-hit ratio", "refId": "A" } diff --git a/chart/templates/externalsecret.yaml b/chart/templates/externalsecret.yaml index ab16cb4..b163d4e 100644 --- a/chart/templates/externalsecret.yaml +++ b/chart/templates/externalsecret.yaml @@ -43,16 +43,22 @@ spec: remoteRef: key: {{ .Values.externalSecret.remoteRefSecret }} property: SLACK_APP_TOKEN - # Optional direct-API provider keys — present in the secret only when an - # alternate provider is in use. Bedrock (default) needs neither. + # Optional direct-API provider keys — synced ONLY when an alternate provider + # is selected. Requesting a property that doesn't exist in the secret fails + # the whole ExternalSecret sync, so a Bedrock-default deploy (which needs + # neither key) must not reference them. + {{- if eq .Values.env.LLM_PROVIDER "anthropic" }} - secretKey: ANTHROPIC_API_KEY remoteRef: key: {{ .Values.externalSecret.remoteRefSecret }} property: ANTHROPIC_API_KEY + {{- end }} + {{- if or (eq .Values.env.LLM_PROVIDER "openai") (eq .Values.env.EMBEDDING_PROVIDER "openai") }} - secretKey: OPENAI_API_KEY remoteRef: key: {{ .Values.externalSecret.remoteRefSecret }} property: OPENAI_API_KEY + {{- end }} # Aurora master credentials from the landing-zone-managed secret. - secretKey: PGUSER remoteRef: diff --git a/chart/templates/prometheusrule.yaml b/chart/templates/prometheusrule.yaml index c063b3b..ad728bc 100644 --- a/chart/templates/prometheusrule.yaml +++ b/chart/templates/prometheusrule.yaml @@ -2,9 +2,12 @@ # Alert rules for the competitive-intelligence radar. Metric names target the # OTel-exported metrics as they arrive in Mimir — the app emits via its OTel # metrics surface (counters + histograms over @opentelemetry/api), and the -# cluster-level OTel Collector ships them to Mimir. The OTel service.name -# `competitive-intelligence` becomes the `competitive_intelligence_*` metric -# prefix after the OTLP→Prometheus naming convention is applied. +# cluster-level OTel Collector ships them to Mimir. The `competitive_intelligence_*` +# prefix and the `deployment_environment` label come from the collector's +# Prometheus pipeline (a `competitive_intelligence` namespace + +# `resource_to_telemetry_conversion`), not the bare OTLP→Prometheus convention; +# counters gain `_total` and histograms `_bucket` per that convention. The +# collector config lives in eks-gitops — see chart/README.md § observability. apiVersion: monitoring.coreos.com/v1 kind: PrometheusRule diff --git a/chart/values.yaml b/chart/values.yaml index 9fcf60f..5c95973 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -51,8 +51,10 @@ resources: podSecurityContext: runAsNonRoot: true - runAsUser: 1000 - fsGroup: 1000 + # Matches the image's named `app` user (UID 1001, Dockerfile). A mismatch here + # leaves the process unable to read files owned by the image user. + runAsUser: 1001 + fsGroup: 1001 seccompProfile: type: RuntimeDefault @@ -151,6 +153,16 @@ networkPolicy: ports: - protocol: TCP port: 5432 + # OTLP traces + metrics to the cluster OTel Collector + # (otel-collector.observability.svc.cluster.local:4318). Without this rule + # the default-deny policy drops every span and metric the app emits. + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: observability + ports: + - protocol: TCP + port: 4318 env: # Non-secret configuration. Secrets (Slack tokens, optional provider keys, @@ -158,9 +170,11 @@ env: AWS_REGION: us-west-2 LLM_PROVIDER: bedrock EMBEDDING_PROVIDER: bedrock - # Sonnet default tier per llm-policy. Verify model availability in region - # before promoting — cross-region inference profiles differ by region. - BEDROCK_LLM_MODEL: anthropic.claude-sonnet-4-6 + # Cross-region Claude Sonnet inference profile (Converse requires the + # us.anthropic.*-v1:0 profile form, not a bare alias). Matches the config.ts + # default. Verify the profile exists in the deploy region before promoting and + # bump to the current Sonnet snapshot when rolling regions. + BEDROCK_LLM_MODEL: us.anthropic.claude-sonnet-4-20250514-v1:0 BEDROCK_EMBEDDING_MODEL: amazon.titan-embed-text-v2:0 EMBEDDING_DIMENSIONS: "1024" # Durable vector store — survives restarts so the first post-restart crawl @@ -176,12 +190,18 @@ env: NODE_ENV: production # OTel — exports to the cluster-level Collector (otel-collector.observability.svc) # provisioned by eks-gitops. No per-pod sidecar. + # Telemetry is initialized once, by the Dockerfile's + # `--require @opentelemetry/auto-instrumentations-node/register` preload, so + # all export config is env-driven here (no programmatic SDK in app code). OTEL_SERVICE_NAME: competitive-intelligence OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector.observability.svc.cluster.local:4318 OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf OTEL_TRACES_SAMPLER: always_on + OTEL_TRACES_EXPORTER: otlp OTEL_METRICS_EXPORTER: otlp OTEL_METRIC_EXPORT_INTERVAL: "60000" + # fs spans are noise for a long-lived crawler — drop them at the preload. + OTEL_NODE_DISABLED_INSTRUMENTATIONS: fs # Resource attrs — agents.tenant/agents.platform required by PLATFORM_TENANT_CONTRACT. OTEL_RESOURCE_ATTRIBUTES: "service.name=competitive-intelligence,service.version=0.1.0,agents.tenant=protohype,agents.platform=competitive-intelligence" diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 0000000..45cf898 --- /dev/null +++ b/docs/RUNBOOK.md @@ -0,0 +1,83 @@ +# competitive-intelligence — Runbook + +On-call reference for the `competitive-intelligence` Platform tenant. Pairs with +[`ARCHITECTURE.md`](../ARCHITECTURE.md) (how it works) and `chart/README.md` +(how it deploys). + +## At a glance + +- **What it is:** a single-writer radar — crawls competitor pages, semantic-diffs + each against its own pgvector history, and alerts Slack (`#competitive-intel`) + on meaningful change. +- **Topology:** `replicaCount: 1` (single writer; do not scale without leader + election). Durable state in Aurora Serverless v2 (pgvector). Bedrock for + LLM + embeddings via IRSA. +- **Probes:** `/health` (liveness), `/readyz` (readiness — fails when the vector + store is unreachable). HTTP server on `PORT` regardless of Slack transport. +- **Logs:** structured JSON to stderr → cluster log forwarder → Grafana Cloud Loki. +- **Telemetry:** OTLP traces + metrics → `otel-collector.observability` → Tempo + Mimir. + +## Dashboards + +Grafana dashboard `competitive-intelligence` (from `chart/dashboards/`). Key panels: + +- **Crawl — duration + sources by outcome** (`crawl_duration_ms`, `crawl_sources_total{outcome}`) +- **Pipeline — chunks + diffs processed, change-score distribution** +- **Alerts — fired vs send failures** +- **Bedrock — token usage by kind + cache-hit ratio** +- **Resilience — circuit breakers open & pgvector errors** + +## Alerts → response + +Alert rules live in `chart/templates/prometheusrule.yaml`. All metric names are +the `competitive_intelligence_*` series the rules query. + +| Alert | Severity | Symptom | Likely cause | First steps | +|---|---|---|---|---| +| `CompetitiveIntelligenceCrawlFailureSpike` | page | ≥3 crawl failures / 15m | A target blocking the crawler, a per-host breaker stuck open, DNS/egress regression, or the NetworkPolicy denying outbound :443 | Check the "Crawl — sources by outcome" panel for which source(s) fail; tail logs for `crawl failed`; confirm the source URL still resolves and serves; check the egress NetworkPolicy. | +| `CompetitiveIntelligenceCircuitBreakerOpen` | warning | A breaker open ≥10m (`$labels.target`) | A host (crawl target, Bedrock, or Slack) failing fast and not recovering | Identify `target` from the alert; check that dependency directly (Bedrock model access / region, Slack API status, the target site). The breaker half-opens automatically once the dependency recovers. | +| `CompetitiveIntelligenceAlertSendFailure` | page | Slack sends failing / 5m | Bad/expired bot token, bot not in `#competitive-intel`, or Slack egress blocked | Verify `SLACK_BOT_TOKEN` (ExternalSecret synced), confirm bot membership in the channel, check Slack API status and the :443 egress path. Detected changes are computed but not delivered until resolved. | +| `CompetitiveIntelligencePgVectorUnreachable` | page | pgvector errors / 5m | Aurora down/failing over, the `db-credentials` ExternalSecret stale, or :5432 egress blocked | Check the Aurora endpoint health and recent failover events; confirm the `competitive-intelligence//db-credentials` secret synced; check the :5432 egress rule. `/readyz` will be failing, so the pod is pulled from rotation until recovery. | + +## Playbooks + +### Pod restart / rollout + +No action. The vector backend is durable pgvector, so the first crawl after a +restart diffs against real history (not a flood of "everything is new"). If the +store happens to be empty for a source, the cold-start guard seeds it as a +baseline and suppresses alerts for that source on that crawl. (See +ARCHITECTURE.md → "Durable pgvector … cold-start baseline guard".) + +### Aurora failover recovery + +Transient `pgvector` errors are expected during a failover; the +`PgVectorUnreachable` alert may fire briefly. Once Aurora is back, confirm the +history is intact (the dashboard's pipeline panels should resume) and watch the +next crawl — because history persisted, it diffs normally rather than flooding +alerts. No manual reseed is required. + +### Force a re-baseline for one source + +If a source's stored chunks are corrupt or you want to reset its history, +delete that source's chunks from pgvector: + +```sql +DELETE FROM ci_vectors WHERE metadata @> '{"sourceId":""}'::jsonb; +``` + +The next crawl sees `count() == 0` for that source, treats it as cold-start +baseline seeding (ingest + embed, **no alert**), and resumes normal diffing on +the crawl after that. Scope the delete to one `sourceId` — a full-table wipe +re-baselines every source and silences one crawl's worth of real changes. + +### Trigger an immediate crawl + +`/competitive-intelligence crawl` in Slack, or `npm run crawl` locally. The crawl +mutex serializes it against the scheduler, so it's safe to run anytime. + +### "Alerts stopped but crawls succeed" + +Check, in order: the `AlertSendFailure` alert (Slack delivery), the +`SIGNIFICANCE_THRESHOLD` (too high suppresses everything), and whether recent +diffs are all baseline/below-threshold (the "change-score distribution" panel). diff --git a/package-lock.json b/package-lock.json index c02db0c..3631436 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,15 +8,10 @@ "name": "competitive-intelligence", "version": "0.1.0", "dependencies": { - "@anthropic-ai/sdk": "^0.89.0", + "@anthropic-ai/sdk": "^0.105.0", "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/auto-instrumentations-node": "^0.76.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.218.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", - "@opentelemetry/resources": "^2.6.1", - "@opentelemetry/sdk-node": "^0.218.0", - "@opentelemetry/semantic-conventions": "^1.40.0", "@slack/bolt": "^4.7.0", "cheerio": "^1.2.0", "dotenv": "^17.4.2", @@ -28,6 +23,7 @@ "@eslint/js": "^10.0.1", "@types/node": "^24.12.2", "@types/pg": "^8.11.0", + "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", "prettier": "^3.8.2", "tsx": "^4.21.0", @@ -40,12 +36,13 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.89.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.89.0.tgz", - "integrity": "sha512-nyGau0zex62EpU91hsHa0zod973YEoiMgzWZ9hC55WdiOLrE4AGpcg4wXI7lFqtvMLqMcLfewQU9sHgQB6psow==", + "version": "0.105.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.105.0.tgz", + "integrity": "sha512-sDyu+aM9cE6uZE+HgRjjHRb+qqb87GHZOx+8bE0YlWetdL1YcVLxn8h9ltxGOflyChTe6PMEo50kMQV4cw0hfg==", "license": "MIT", "dependencies": { - "json-schema-to-ts": "^3.1.1" + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" }, "bin": { "anthropic-ai-sdk": "bin/cli" @@ -773,6 +770,42 @@ "node": ">=18.0.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -782,10 +815,34 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, @@ -795,9 +852,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -817,9 +874,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -834,9 +891,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -851,9 +908,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -868,9 +925,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -885,9 +942,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -902,9 +959,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -919,9 +976,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -936,9 +993,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -953,9 +1010,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -970,9 +1027,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -987,9 +1044,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -1004,9 +1061,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -1021,9 +1078,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -1038,9 +1095,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -1055,9 +1112,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -1072,9 +1129,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -1089,9 +1146,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -1106,9 +1163,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -1123,9 +1180,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -1140,9 +1197,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -1157,9 +1214,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -1174,9 +1231,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -1191,9 +1248,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -1208,9 +1265,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -1225,9 +1282,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1242,9 +1299,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1469,6 +1526,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1476,6 +1543,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -1487,14 +1565,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", - "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -1630,9 +1708,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2874,9 +2952,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", - "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -2922,12 +3000,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -2947,9 +3019,9 @@ "license": "BSD-3-Clause" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -2964,9 +3036,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -2981,9 +3053,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -2998,9 +3070,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -3015,9 +3087,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", - "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -3032,13 +3104,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3049,13 +3124,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3066,13 +3144,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3083,13 +3164,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3100,13 +3184,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3117,13 +3204,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3134,9 +3224,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -3151,9 +3241,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", - "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -3161,18 +3251,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.3" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -3187,9 +3277,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -3204,9 +3294,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", - "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -3955,6 +4045,12 @@ "node": ">=18.0.0" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3963,9 +4059,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -4440,17 +4536,48 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", - "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4459,13 +4586,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", - "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.4", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4486,9 +4613,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", - "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -4499,13 +4626,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", - "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -4513,14 +4640,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", - "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4529,9 +4656,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -4539,13 +4666,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", - "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4660,6 +4787,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4750,9 +4889,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -5248,9 +5387,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5261,32 +5400,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -5574,6 +5713,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -5745,16 +5890,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -5984,6 +6129,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -6012,9 +6167,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6023,6 +6178,13 @@ "node": ">= 0.4" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -6212,6 +6374,52 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -6465,6 +6673,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6486,6 +6697,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6507,6 +6721,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6528,6 +6745,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6663,6 +6883,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6747,9 +6995,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", "dev": true, "funding": [ { @@ -7222,9 +7470,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -7242,7 +7490,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7316,9 +7564,9 @@ } }, "node_modules/protobufjs": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", - "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -7328,7 +7576,6 @@ "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", @@ -7372,9 +7619,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -7468,14 +7715,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", - "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.124.0", - "@rolldown/pluginutils": "1.0.0-rc.15" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -7484,21 +7731,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-x64": "1.0.0-rc.15", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/router": { @@ -7734,6 +7981,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7788,6 +8045,19 @@ ], "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -7806,9 +8076,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -7961,9 +8231,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -8004,17 +8274,17 @@ } }, "node_modules/vite": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", - "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.15", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -8030,7 +8300,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8082,19 +8352,19 @@ } }, "node_modules/vitest": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", - "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.4", - "@vitest/mocker": "4.1.4", - "@vitest/pretty-format": "4.1.4", - "@vitest/runner": "4.1.4", - "@vitest/snapshot": "4.1.4", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8122,12 +8392,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.4", - "@vitest/browser-preview": "4.1.4", - "@vitest/browser-webdriverio": "4.1.4", - "@vitest/coverage-istanbul": "4.1.4", - "@vitest/coverage-v8": "4.1.4", - "@vitest/ui": "4.1.4", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -8269,9 +8539,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 990d93a..fc721b9 100644 --- a/package.json +++ b/package.json @@ -22,18 +22,14 @@ "chart:template:staging": "helm template competitive-intelligence chart -f chart/values-staging.yaml", "chart:template:production": "helm template competitive-intelligence chart -f chart/values-production.yaml", "crawl": "tsx src/cli.ts crawl", - "query": "tsx src/cli.ts query" + "query": "tsx src/cli.ts query", + "test:coverage": "vitest run --coverage" }, "dependencies": { - "@anthropic-ai/sdk": "^0.89.0", + "@anthropic-ai/sdk": "^0.105.0", "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/auto-instrumentations-node": "^0.76.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.218.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", - "@opentelemetry/resources": "^2.6.1", - "@opentelemetry/sdk-node": "^0.218.0", - "@opentelemetry/semantic-conventions": "^1.40.0", "@slack/bolt": "^4.7.0", "cheerio": "^1.2.0", "dotenv": "^17.4.2", @@ -45,11 +41,24 @@ "@eslint/js": "^10.0.1", "@types/node": "^24.12.2", "@types/pg": "^8.11.0", + "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", "prettier": "^3.8.2", "tsx": "^4.21.0", "typescript": "^6.0.2", "typescript-eslint": "^8.58.2", "vitest": "^4.1.4" + }, + "overrides": { + "ws": "^8.21.0", + "form-data": "^4.0.6", + "undici": "^7.28.0", + "vite": "^8.0.16", + "brace-expansion": "^5.0.6", + "postcss": "^8.5.15", + "protobufjs": "^7.6.4", + "qs": "^6.15.2", + "esbuild": "^0.28.1", + "@opentelemetry/core": "2.8.0" } } diff --git a/src/alerts/index.test.ts b/src/alerts/index.test.ts new file mode 100644 index 0000000..a98ebc0 --- /dev/null +++ b/src/alerts/index.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from "vitest"; +import { createAlertEngine, type AlertSink } from "./index.js"; +import type { DiffResult } from "../pipeline/differ.js"; +import type { LlmProvider, LlmResponse } from "../providers/llm.js"; +import type { Chunk } from "../pipeline/chunker.js"; +import type { Config } from "../config.js"; +import type { SlackBlocks } from "./formatter.js"; + +function makeConfig(overrides: Partial = {}): Config { + return { + llmProvider: "anthropic", + embeddingModel: "text-embedding-3-small", + embeddingDimensions: 1024, + vectorProvider: "memory", + crawlIntervalMinutes: 60, + crawlTimeoutMs: 30_000, + userAgent: "test", + slackAlertChannel: "#test", + significanceThreshold: 0.3, + port: 3000, + nodeEnv: "test", + logLevel: "error", + ...overrides, + } as Config; +} + +// Fake provider implementing the interface directly (no SDK mocking) — returns +// canned analysis JSON. +function fakeLlm(json: string): LlmProvider { + const response: LlmResponse = { + text: json, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }; + return { + async chat() { + return response; + }, + }; +} + +// Recording sink; `failing` makes send() reject to exercise the swallow path. +function recordingSink(failing = false): { + sink: AlertSink; + sends: Array<{ channel: string; message: SlackBlocks }>; +} { + const sends: Array<{ channel: string; message: SlackBlocks }> = []; + const sink: AlertSink = { + async send(channel, message) { + if (failing) throw new Error("slack down"); + sends.push({ channel, message }); + }, + }; + return { sink, sends }; +} + +function chunk(text: string): Chunk { + return { + id: "s:0", + text, + index: 0, + sourceId: "acme:pricing", + metadata: { sourceId: "acme:pricing" }, + }; +} + +function makeDiff(overrides: Partial = {}): DiffResult { + return { + sourceId: "acme:pricing", + competitor: "acme", + changeScore: 0.5, + newChunks: [chunk("new enterprise tier launched")], + unchangedChunks: [], + totalChunks: 1, + ...overrides, + }; +} + +const ANALYSIS = JSON.stringify({ + summary: "Acme launched an enterprise tier.", + significance: "high", + signals: ["new enterprise tier"], +}); + +describe("alert engine", () => { + it("sends one alert on the configured channel for a significant diff", async () => { + const { sink, sends } = recordingSink(); + const engine = createAlertEngine(fakeLlm(ANALYSIS), sink, makeConfig()); + + const analyses = await engine.processDiffs([makeDiff()]); + + expect(analyses).toHaveLength(1); + expect(analyses[0].significance).toBe("high"); + expect(sends).toHaveLength(1); + expect(sends[0].channel).toBe("#test"); + }); + + it("does not alert when changeScore is below the threshold", async () => { + const { sink, sends } = recordingSink(); + const engine = createAlertEngine( + fakeLlm(ANALYSIS), + sink, + makeConfig({ significanceThreshold: 0.6 }), + ); + + const analyses = await engine.processDiffs([makeDiff({ changeScore: 0.5 })]); + + expect(analyses).toHaveLength(0); + expect(sends).toHaveLength(0); + }); + + it("does not alert when there are no new chunks even above the threshold", async () => { + const { sink, sends } = recordingSink(); + const engine = createAlertEngine(fakeLlm(ANALYSIS), sink, makeConfig()); + + const analyses = await engine.processDiffs([makeDiff({ changeScore: 0.9, newChunks: [] })]); + + expect(analyses).toHaveLength(0); + expect(sends).toHaveLength(0); + }); + + it("still returns the analysis when the sink send fails", async () => { + const { sink } = recordingSink(true); + const engine = createAlertEngine(fakeLlm(ANALYSIS), sink, makeConfig()); + + // processDiffs must resolve (swallowed send error) and keep the analysis. + const analyses = await engine.processDiffs([makeDiff()]); + expect(analyses).toHaveLength(1); + expect(analyses[0].sourceId).toBe("acme:pricing"); + }); +}); diff --git a/src/alerts/index.ts b/src/alerts/index.ts index f470ac1..81eded2 100644 --- a/src/alerts/index.ts +++ b/src/alerts/index.ts @@ -3,7 +3,8 @@ import type { LlmProvider } from "../providers/llm.js"; import type { Config } from "../config.js"; import { analyzeChanges, type ChangeAnalysis } from "../intel/analysis.js"; import { formatAlert, type SlackBlocks } from "./formatter.js"; -import { logger } from "../logger.js"; +import { logger, toMessage } from "../logger.js"; +import { recordAlertSendFailure, recordAlertFired } from "../metrics.js"; export interface AlertSink { send(channel: string, message: SlackBlocks): Promise; @@ -42,15 +43,17 @@ export function createAlertEngine(llm: LlmProvider, sink: AlertSink, config: Con const message = formatAlert(analysis); try { await sink.send(config.slackAlertChannel, message); + recordAlertFired(); logger.info("alert sent", { sourceId: analysis.sourceId, significance: analysis.significance, channel: config.slackAlertChannel, }); } catch (err) { + recordAlertSendFailure(analysis.sourceId); logger.error("alert send failed", { sourceId: analysis.sourceId, - error: err instanceof Error ? err.message : String(err), + error: toMessage(err), }); } } diff --git a/src/cli.ts b/src/cli.ts index 3339108..554ef3c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,16 +1,13 @@ import { loadConfig } from "./config.js"; -import { logger, setLogLevel } from "./logger.js"; +import { logger, setLogLevel, toMessage } from "./logger.js"; import { bootstrapLlm } from "./providers/llm.js"; import { bootstrapEmbeddings } from "./providers/embeddings.js"; import { bootstrapVectorStore } from "./providers/vectors.js"; import { loadSourcesFromFile } from "./crawler/sources.js"; -import { fetchPage } from "./crawler/fetcher.js"; -import { parseHtml } from "./crawler/parser.js"; +import { crawlAll } from "./crawler/index.js"; import { ingestAndDiff } from "./pipeline/index.js"; import { createIntelEngine } from "./intel/index.js"; import { createAlertEngine } from "./alerts/index.js"; -import type { ParsedContent } from "./crawler/parser.js"; -import type { Source } from "./crawler/sources.js"; import * as ui from "./display.js"; const [command, ...args] = process.argv.slice(2); @@ -35,36 +32,22 @@ async function main(): Promise { ui.header(); ui.crawlStart(sources); - // Crawl with per-source progress - const succeeded: ParsedContent[] = []; - const failed: Array<{ source: Source; error: string }> = []; - - for (const source of sources) { - try { - const result = await fetchPage(source.url, { - timeoutMs: config.crawlTimeoutMs, - userAgent: config.userAgent, - }); - const parsed = parseHtml(result.html, source, result.fetchedAt); - succeeded.push(parsed); - ui.crawlSourceResult(source, { ok: true, parsed }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - failed.push({ source, error: message }); - ui.crawlSourceResult(source, { ok: false, error: message }); - } - } - - const crawlResult = { succeeded, failed }; + // Reuse the shared crawl loop, streaming per-source progress through the + // callback instead of duplicating fetch→parse→collect here. + const crawlResult = await crawlAll(sources, { + timeoutMs: config.crawlTimeoutMs, + userAgent: config.userAgent, + onResult: ui.crawlSourceResult, + }); - if (succeeded.length === 0) { - ui.failuresDetail(failed); + if (crawlResult.succeeded.length === 0) { + ui.failuresDetail(crawlResult.failed); console.error("\n All crawls failed. Nothing to process.\n"); process.exit(1); } // Pipeline - const pipeline = await ingestAndDiff(succeeded, embedder, store); + const pipeline = await ingestAndDiff(crawlResult.succeeded, embedder, store); ui.pipelineSummary(crawlResult, pipeline); // Analysis @@ -79,7 +62,7 @@ async function main(): Promise { } // Failures - ui.failuresDetail(failed); + ui.failuresDetail(crawlResult.failed); // Summary ui.summary(crawlResult, pipeline, analyses, sources.length); @@ -122,6 +105,6 @@ async function main(): Promise { } main().catch((err) => { - logger.error("cli error", { error: err instanceof Error ? err.message : String(err) }); + logger.error("cli error", { error: toMessage(err) }); process.exit(1); }); diff --git a/src/config.ts b/src/config.ts index 8ec248e..c93fc3d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,11 +14,19 @@ const schema = z bedrockLlmModel: z.string().default("us.anthropic.claude-sonnet-4-20250514-v1:0"), bedrockEmbeddingModel: z.string().default("amazon.titan-embed-text-v2:0"), + // Direct-API model IDs for the non-Bedrock providers (injectable, not + // hardcoded). Anthropic-direct uses the bare current Sonnet alias. + anthropicLlmModel: z.string().default("claude-sonnet-4-6"), + openaiLlmModel: z.string().default("gpt-4o"), + embeddingModel: z.string().default("text-embedding-3-small"), embeddingDimensions: z.number().default(1024), vectorProvider: z.enum(["memory", "pgvector"]).default("memory"), databaseUrl: z.string().optional(), + // Path to a mounted CA bundle (e.g. the Amazon RDS global CA) for verifying + // the pgvector TLS connection. Unset → Node's built-in trust store. + pgCaPath: z.string().optional(), crawlIntervalMinutes: z.number().default(60), crawlTimeoutMs: z.number().default(30_000), @@ -86,10 +94,13 @@ export function loadConfig(): Config { awsRegion: process.env.AWS_REGION, bedrockLlmModel: process.env.BEDROCK_LLM_MODEL, bedrockEmbeddingModel: process.env.BEDROCK_EMBEDDING_MODEL, + anthropicLlmModel: process.env.ANTHROPIC_LLM_MODEL, + openaiLlmModel: process.env.OPENAI_LLM_MODEL, embeddingModel: process.env.EMBEDDING_MODEL, embeddingDimensions: num(process.env.EMBEDDING_DIMENSIONS), vectorProvider: process.env.VECTOR_PROVIDER, databaseUrl: process.env.DATABASE_URL, + pgCaPath: process.env.PG_CA_PATH, crawlIntervalMinutes: num(process.env.CRAWL_INTERVAL_MINUTES), crawlTimeoutMs: num(process.env.CRAWL_TIMEOUT_MS), userAgent: process.env.USER_AGENT, diff --git a/src/crawler/index.ts b/src/crawler/index.ts index 4754af9..eecc753 100644 --- a/src/crawler/index.ts +++ b/src/crawler/index.ts @@ -2,21 +2,29 @@ import type { Source } from "./sources.js"; import type { ParsedContent } from "./parser.js"; import { fetchPage } from "./fetcher.js"; import { parseHtml } from "./parser.js"; -import { logger } from "../logger.js"; +import { logger, toMessage } from "../logger.js"; +import { recordCrawlSource, recordCrawlFailure } from "../metrics.js"; export interface CrawlResult { succeeded: ParsedContent[]; failed: Array<{ source: Source; error: string }>; } +/** Per-source outcome, shaped to match `display.crawlSourceResult`. */ +export type CrawlSourceOutcome = { ok: true; parsed: ParsedContent } | { ok: false; error: string }; + +export interface CrawlOptions { + timeoutMs: number; + userAgent: string; + /** Invoked per source as it completes — lets callers stream progress. */ + onResult?: (source: Source, outcome: CrawlSourceOutcome) => void; +} + /** * Crawl all configured sources, fetch HTML, and parse to structured content. * Failures are collected rather than thrown — partial results are always returned. */ -export async function crawlAll( - sources: Source[], - options: { timeoutMs: number; userAgent: string }, -): Promise { +export async function crawlAll(sources: Source[], options: CrawlOptions): Promise { const succeeded: ParsedContent[] = []; const failed: CrawlResult["failed"] = []; @@ -27,10 +35,15 @@ export async function crawlAll( const result = await fetchPage(source.url, options); const parsed = parseHtml(result.html, source, result.fetchedAt); succeeded.push(parsed); + recordCrawlSource("succeeded"); + options.onResult?.(source, { ok: true, parsed }); } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = toMessage(err); logger.warn("crawl failed", { sourceId: source.id, url: source.url, error: message }); failed.push({ source, error: message }); + recordCrawlSource("failed"); + recordCrawlFailure(source.id); + options.onResult?.(source, { ok: false, error: message }); } } diff --git a/src/crawler/parser.test.ts b/src/crawler/parser.test.ts new file mode 100644 index 0000000..4ce47f1 --- /dev/null +++ b/src/crawler/parser.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { parseHtml } from "./parser.js"; +import type { Source } from "./sources.js"; + +const source: Source = { + id: "acme:pricing", + competitor: "acme", + url: "https://acme.example.com/pricing", + type: "pricing", +}; + +function parse(html: string) { + return parseHtml(html, source, new Date(0)); +} + +describe("parseHtml", () => { + it("extracts title and whitespace-collapsed text", () => { + const r = parse("Acme

Plans

\n\n Pro tier"); + expect(r.title).toBe("Acme"); + expect(r.text).toContain("Pro tier"); + }); + + it("strips script/style noise from extracted text", () => { + const r = parse("real"); + expect(r.text).toBe("real"); + }); + + it("keeps http(s) links, resolving relative URLs against the source", () => { + const r = parse( + 'ab', + ); + expect(r.links).toContain("https://acme.example.com/plans"); + expect(r.links).toContain("https://other.example.com/x"); + }); + + it("drops non-http(s) link schemes (allowlist, not a javascript: blocklist)", () => { + const r = parse( + [ + 'x', + 'x', // mixed case + 'x', // leading whitespace + 'x', + 'x', + 'ok', + ].join(""), + ); + expect(r.links).toEqual(["https://ok.example.com/keep"]); + }); + + it("dedupes repeated links", () => { + const r = parse('12'); + expect(r.links).toEqual(["https://acme.example.com/p"]); + }); +}); diff --git a/src/crawler/parser.ts b/src/crawler/parser.ts index cb770e2..97fe6fd 100644 --- a/src/crawler/parser.ts +++ b/src/crawler/parser.ts @@ -41,13 +41,18 @@ export function parseHtml(html: string, source: Source, fetchedAt: Date): Parsed const links: string[] = []; scope.find("a[href]").each((_, el) => { const href = $(el).attr("href"); - if (href && !href.startsWith("#") && !href.startsWith("javascript:")) { - try { - const resolved = new URL(href, source.url).toString(); - links.push(resolved); - } catch { - // skip malformed URLs + if (!href || href.startsWith("#")) return; + try { + const resolved = new URL(href, source.url); + // Allowlist safe schemes on the RESOLVED url. Blocklisting `javascript:` + // alone is incomplete — `data:`, `vbscript:`, mixed-case (`JavaScript:`), + // and whitespace-padded variants all slip a substring check; resolving + // first and gating on the normalized protocol closes all of them. + if (resolved.protocol === "http:" || resolved.protocol === "https:") { + links.push(resolved.toString()); } + } catch { + // skip malformed URLs } }); diff --git a/src/crawler/url-guard.test.ts b/src/crawler/url-guard.test.ts index 2276824..f8fa757 100644 --- a/src/crawler/url-guard.test.ts +++ b/src/crawler/url-guard.test.ts @@ -36,6 +36,20 @@ describe("guardUrl", () => { }, ); + it.each([ + "http://[::ffff:127.0.0.1]/", // mapped loopback + "http://[::ffff:169.254.169.254]/latest/meta-data/", // mapped AWS metadata + "http://[::ffff:10.0.0.1]/", // mapped RFC1918 + "http://[::ffff:192.168.1.1]/", // mapped RFC1918 + ])("rejects IPv4-mapped IPv6 that embeds a blocked address %s", async (url) => { + await expect(guardUrl(url)).rejects.toThrow(/blocked address/); + }); + + it("still accepts an IPv4-mapped IPv6 wrapping a public address", async () => { + // ::ffff:93.184.216.34 (example.com) — mapped, but public, so allowed. + await expect(guardUrl("http://[::ffff:93.184.216.34]/")).resolves.toBeInstanceOf(URL); + }); + it("rejects malformed URLs", async () => { await expect(guardUrl("not a url")).rejects.toThrow(/malformed URL/); }); diff --git a/src/crawler/url-guard.ts b/src/crawler/url-guard.ts index b490ba3..971f8ef 100644 --- a/src/crawler/url-guard.ts +++ b/src/crawler/url-guard.ts @@ -74,10 +74,35 @@ function isBlockedAddress(addr: string): boolean { return false; } - // IPv6: loopback, link-local (fe80::/10), unique-local (fc00::/7), unspecified. const lower = addr.toLowerCase(); + + // IPv4-mapped IPv6 (::ffff:a.b.c.d). `new URL()` normalizes the dotted tail to + // a hex-quad (::ffff:7f00:1, ::ffff:a9fe:a9fe), so decode both forms and + // re-gate on the embedded IPv4 — otherwise mapped loopback / RFC1918 / cloud + // metadata slips past the IPv4 checks above. + if (lower.startsWith("::ffff:")) { + const mapped = ipv4FromMapped(lower.slice("::ffff:".length)); + if (mapped && isBlockedAddress(mapped)) return true; + } + + // IPv6: loopback, link-local (fe80::/10), unique-local (fc00::/7), unspecified. if (lower === "::1" || lower === "::") return true; if (lower.startsWith("fe80:") || lower.startsWith("fe80::")) return true; if (lower.startsWith("fc") || lower.startsWith("fd")) return true; return false; } + +/** Decode the tail of an IPv4-mapped IPv6 address to dotted IPv4, or null. */ +function ipv4FromMapped(tail: string): string | null { + // Dotted form (::ffff:1.2.3.4) — rare after URL normalization, but cheap to handle. + if (tail.includes(".")) { + return isIP(tail) === 4 ? tail : null; + } + // Hex-quad form (::ffff:7f00:1) — two 16-bit groups = the embedded 4 bytes. + const groups = tail.split(":"); + if (groups.length !== 2) return null; + const hi = Number.parseInt(groups[0], 16); + const lo = Number.parseInt(groups[1], 16); + if (Number.isNaN(hi) || Number.isNaN(lo)) return null; + return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join("."); +} diff --git a/src/index.ts b/src/index.ts index 348b55e..81d9ca0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,6 @@ -import { startTelemetry, stopTelemetry } from "./otel.js"; import http from "node:http"; import { loadConfig } from "./config.js"; -import { logger, setLogLevel } from "./logger.js"; +import { logger, setLogLevel, toMessage } from "./logger.js"; import { bootstrapLlm } from "./providers/llm.js"; import { bootstrapEmbeddings } from "./providers/embeddings.js"; import { bootstrapVectorStore, type VectorStore } from "./providers/vectors.js"; @@ -12,7 +11,10 @@ import { createIntelEngine } from "./intel/index.js"; import { createAlertEngine, type AlertSink } from "./alerts/index.js"; import { createSlackBot } from "./slack/index.js"; import { createScheduler } from "./scheduler/index.js"; -import { recordCrawlDuration, recordDiffsProcessed, recordAlertFired } from "./metrics.js"; +import { recordCrawlDuration } from "./metrics.js"; + +/** Outcome of a crawl trigger, so callers (the slash command) can report honestly. */ +export type CrawlOutcome = "ran" | "skipped"; /** * Tiny liveness/readiness server. @@ -44,7 +46,7 @@ function createHealthServer(store: VectorStore): http.Server { res.end( JSON.stringify({ status: "unready", - error: err instanceof Error ? err.message : String(err), + error: toMessage(err), }), ); }); @@ -56,10 +58,12 @@ function createHealthServer(store: VectorStore): http.Server { } async function main(): Promise { - // Start telemetry FIRST so auto-instrumentation patches http/fetch/aws-sdk - // before any client below is constructed. - startTelemetry(); - + // Telemetry is initialized by the Dockerfile's + // `--require @opentelemetry/auto-instrumentations-node/register` preload — it + // must load before any instrumented module is imported, which a call here + // (after the import graph is already resolved) cannot guarantee. All OTel + // config is env-driven (see the chart's OTEL_* env). Locally, telemetry is + // simply absent and the OTel API degrades to a no-op. logger.info("competitive-intelligence starting"); // ─── Config ─── @@ -78,15 +82,15 @@ async function main(): Promise { // Mutex prevents overlapping runs from scheduler + slash command racing. let crawlInProgress = false; - async function runCrawl(): Promise { + async function runCrawl(): Promise { if (crawlInProgress) { logger.warn("crawl already in progress, skipping"); - return; + return "skipped"; } if (sources.length === 0) { logger.warn("no sources configured, skipping crawl"); - return; + return "skipped"; } crawlInProgress = true; @@ -99,14 +103,14 @@ async function main(): Promise { if (crawlResult.succeeded.length === 0) { logger.warn("all crawls failed, nothing to process"); - return; + return "ran"; } + // recordDiffsProcessed / recordAlertFired are emitted inside + // ingestAndDiff / the alert engine so the CLI path counts them too. const pipelineResult = await ingestAndDiff(crawlResult.succeeded, embedder, store); - recordDiffsProcessed(pipelineResult.diffs.length); - - const analyses = await alertEngine.processDiffs(pipelineResult.diffs); - if (analyses.length > 0) recordAlertFired(analyses.length); + await alertEngine.processDiffs(pipelineResult.diffs); + return "ran"; } finally { crawlInProgress = false; recordCrawlDuration(Date.now() - startedAt); @@ -137,7 +141,10 @@ async function main(): Promise { { name: "crawl", intervalMs: config.crawlIntervalMinutes * 60 * 1000, - fn: runCrawl, + // Discard the outcome — the scheduler job is fire-and-forget (void). + fn: async () => { + await runCrawl(); + }, }, ]); @@ -176,7 +183,8 @@ async function main(): Promise { scheduler.stop(); if (slackBot) await slackBot.stop(); await new Promise((resolve) => healthServer.close(() => resolve())); - await stopTelemetry(); + // The auto-instrumentations preload registers its own SIGTERM/beforeExit + // flush, so there is no programmatic SDK to shut down here. process.exit(0); }; @@ -185,6 +193,6 @@ async function main(): Promise { } main().catch((err) => { - logger.error("fatal", { error: err instanceof Error ? err.message : String(err) }); + logger.error("fatal", { error: toMessage(err) }); process.exit(1); }); diff --git a/src/intel/analysis.test.ts b/src/intel/analysis.test.ts new file mode 100644 index 0000000..42df5e6 --- /dev/null +++ b/src/intel/analysis.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { analyzeChanges, stripCodeFences } from "./analysis.js"; +import type { DiffResult } from "../pipeline/differ.js"; +import type { LlmProvider, LlmResponse } from "../providers/llm.js"; +import type { Chunk } from "../pipeline/chunker.js"; + +function fakeLlm(text: string): LlmProvider { + const response: LlmResponse = { + text, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }; + return { + async chat() { + return response; + }, + }; +} + +function chunk(text: string): Chunk { + return { + id: "s:0", + text, + index: 0, + sourceId: "acme:pricing", + metadata: { sourceId: "acme:pricing" }, + }; +} + +const diff: DiffResult = { + sourceId: "acme:pricing", + competitor: "acme", + changeScore: 0.7, + newChunks: [chunk("new content")], + unchangedChunks: [], + totalChunks: 1, +}; + +describe("stripCodeFences", () => { + it("returns bare JSON unchanged", () => { + expect(stripCodeFences('{"a":1}')).toBe('{"a":1}'); + }); + it("strips a ```json fence", () => { + expect(stripCodeFences('```json\n{"a":1}\n```')).toBe('{"a":1}'); + }); + it("strips a plain ``` fence", () => { + expect(stripCodeFences('```\n{"a":1}\n```')).toBe('{"a":1}'); + }); +}); + +describe("analyzeChanges", () => { + it("passes through clean JSON", async () => { + const llm = fakeLlm( + JSON.stringify({ summary: "Launched X", significance: "high", signals: ["X"] }), + ); + const a = await analyzeChanges(diff, llm); + expect(a.summary).toBe("Launched X"); + expect(a.significance).toBe("high"); + expect(a.signals).toEqual(["X"]); + expect(a.sourceId).toBe("acme:pricing"); + }); + + it("parses fenced JSON", async () => { + const llm = fakeLlm('```json\n{"summary":"Y","significance":"medium","signals":[]}\n```'); + const a = await analyzeChanges(diff, llm); + expect(a.summary).toBe("Y"); + expect(a.significance).toBe("medium"); + }); + + it("clamps an invalid significance to low while keeping the parsed summary", async () => { + const llm = fakeLlm(JSON.stringify({ summary: "Z", significance: "urgent", signals: ["a"] })); + const a = await analyzeChanges(diff, llm); + expect(a.significance).toBe("low"); + expect(a.summary).toBe("Z"); // from parsed JSON, NOT the raw-text fallback + expect(a.signals).toEqual(["a"]); + }); + + it("defaults a missing summary", async () => { + const llm = fakeLlm(JSON.stringify({ significance: "low", signals: [] })); + const a = await analyzeChanges(diff, llm); + expect(a.summary).toBe("Analysis unavailable"); + }); + + it("falls back to raw text on non-JSON output", async () => { + const prose = "The competitor appears to have updated pricing significantly."; + const a = await analyzeChanges(diff, fakeLlm(prose)); + expect(a.summary).toBe(prose.slice(0, 500)); + expect(a.significance).toBe("low"); + expect(a.signals).toEqual([]); + }); +}); diff --git a/src/intel/analysis.ts b/src/intel/analysis.ts index eef428a..3fd790b 100644 --- a/src/intel/analysis.ts +++ b/src/intel/analysis.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import type { LlmProvider } from "../providers/llm.js"; import type { DiffResult } from "../pipeline/differ.js"; import type { SearchResult } from "../providers/vectors.js"; @@ -10,6 +11,14 @@ export interface ChangeAnalysis { signals: string[]; } +// The model returns JSON — validate it at this trust boundary like every other +// input (config, sources). Unknown shapes fall back to the raw-text branch. +const analysisSchema = z.object({ + summary: z.string().default("Analysis unavailable"), + significance: z.enum(["low", "medium", "high", "critical"]).catch("low"), + signals: z.array(z.string()).catch([]), +}); + const ANALYSIS_SYSTEM = `You are a competitive intelligence analyst. You analyze changes detected on competitor websites and extract actionable intelligence signals. Given new content detected on a competitor's page, produce: @@ -25,7 +34,7 @@ Respond in JSON format: }`; /** Strip markdown code fences (```json ... ```) that LLMs often wrap around JSON. */ -function stripCodeFences(text: string): string { +export function stripCodeFences(text: string): string { const trimmed = text.trim(); const match = trimmed.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?\s*```$/); return match ? match[1] : trimmed; @@ -43,29 +52,37 @@ ${newContent.slice(0, 8000)}`; const response = await llm.chat(ANALYSIS_SYSTEM, prompt); - try { - const parsed = JSON.parse(stripCodeFences(response.text)); - const validSignificance = ["low", "medium", "high", "critical"] as const; - const significance = validSignificance.includes(parsed.significance) - ? (parsed.significance as (typeof validSignificance)[number]) - : "low"; - + const parsed = safeParseAnalysis(stripCodeFences(response.text)); + if (parsed) { return { sourceId: diff.sourceId, competitor: diff.competitor, - summary: parsed.summary ?? "Analysis unavailable", - significance, - signals: Array.isArray(parsed.signals) ? parsed.signals : [], + summary: parsed.summary, + significance: parsed.significance, + signals: parsed.signals, }; + } + + // Not valid JSON — fall back to the raw model text as the summary. + return { + sourceId: diff.sourceId, + competitor: diff.competitor, + summary: response.text.slice(0, 500), + significance: "low", + signals: [], + }; +} + +/** Parse + validate the model's analysis JSON; null when it isn't JSON at all. */ +function safeParseAnalysis(text: string): z.infer | null { + let json: unknown; + try { + json = JSON.parse(text); } catch { - return { - sourceId: diff.sourceId, - competitor: diff.competitor, - summary: response.text.slice(0, 500), - significance: "low", - signals: [], - }; + return null; } + const result = analysisSchema.safeParse(json); + return result.success ? result.data : null; } // ─── Query answering ─── diff --git a/src/logger.ts b/src/logger.ts index 33fc019..68f1374 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -28,3 +28,7 @@ export const logger = { warn: (msg: string, data?: Record) => log("warn", msg, data), error: (msg: string, data?: Record) => log("error", msg, data), }; + +/** Normalize an unknown thrown value to a string message. */ +export const toMessage = (err: unknown): string => + err instanceof Error ? err.message : String(err); diff --git a/src/metrics.ts b/src/metrics.ts index ed7b47b..d45ac49 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -1,21 +1,36 @@ /** * Application metrics via the OTel Metrics API. * - * Exports OTLP to the cluster OTel Collector (the SDK in `otel.ts` wires the - * meter provider; the chart points it at - * `otel-collector.observability.svc.cluster.local:4318` → Grafana Cloud Mimir). + * Exports OTLP to the cluster OTel Collector. The meter provider is installed by + * the Dockerfile's `--require @opentelemetry/auto-instrumentations-node/register` + * preload (env-driven); the chart points it at + * `otel-collector.observability.svc.cluster.local:4318` → Grafana Cloud Mimir. * - * Two generic call-site helpers — `timing` (histogram, ms) and `counter` - * (monotonic counter) — back named convenience wrappers for the hot paths: - * crawl duration, chunks/diffs processed, alerts fired, Bedrock token counts - * (by kind: input/output/cache_read/cache_write), and circuit-breaker trips. + * Generic call-site helpers — `timing` (ms histogram), `distribution` (unitless + * histogram) and `counter` (monotonic counter) — back named convenience wrappers + * for the hot paths: crawl duration + per-source outcome, chunks/diffs processed, + * change-score distribution, alerts fired + send failures, pgvector errors, + * Bedrock token counts (one metric per kind so cache effectiveness is visible), + * and a circuit-breaker open/closed gauge. + * + * Metric names map to the `competitive_intelligence_*` series the chart's Grafana + * dashboard and PrometheusRule query: an OTel instrument named `crawl.failures` + * (a counter) arrives in Mimir as `competitive_intelligence_crawl_failures_total` + * after the OTLP→Prometheus naming convention + the collector's service-name + * namespace are applied. Keep instrument names in sync with the chart. * * When no meter provider is registered (tests, CI, or any run with * `OTEL_SDK_DISABLED=true`), the OTel API degrades to a no-op. That's * intentional: nothing here throws without a backend, so call sites stay * unconditional. */ -import { metrics as otelMetrics, type Counter, type Histogram } from "@opentelemetry/api"; +import { + metrics as otelMetrics, + type Counter, + type Histogram, + type ObservableGauge, + type ObservableResult, +} from "@opentelemetry/api"; const METER_NAME = "competitive-intelligence"; @@ -31,10 +46,12 @@ function getCounter(name: string): Counter { return c; } -function getHistogram(name: string): Histogram { +function getHistogram(name: string, unit: string, boundaries?: number[]): Histogram { let h = histograms.get(name); if (!h) { - h = otelMetrics.getMeter(METER_NAME).createHistogram(name, { unit: "ms" }); + const opts: { unit: string; advice?: { explicitBucketBoundaries: number[] } } = { unit }; + if (boundaries) opts.advice = { explicitBucketBoundaries: boundaries }; + h = otelMetrics.getMeter(METER_NAME).createHistogram(name, opts); histograms.set(name, h); } return h; @@ -43,7 +60,21 @@ function getHistogram(name: string): Histogram { // ─── Generic helpers ─── export function timing(name: string, ms: number, dimensions?: Record): void { - getHistogram(name).record(ms, dimensions); + getHistogram(name, "ms").record(ms, dimensions); +} + +/** + * Record a unitless value into a histogram. `boundaries` sets explicit bucket + * edges — required for 0–1 ratios, which otherwise inherit OTel's default + * ms-scale buckets ([0,5,...]) and collapse every value into the first bucket. + */ +export function distribution( + name: string, + value: number, + dimensions?: Record, + boundaries?: number[], +): void { + getHistogram(name, "1", boundaries).record(value, dimensions); } export function counter(name: string, value = 1, dimensions?: Record): void { @@ -57,6 +88,16 @@ export function recordCrawlDuration(ms: number): void { timing("crawl.duration_ms", ms); } +/** One crawled source, dimensioned by outcome (succeeded | failed). */ +export function recordCrawlSource(outcome: "succeeded" | "failed"): void { + counter("crawl.sources", 1, { outcome }); +} + +/** A crawl failure for one source — backs the CrawlFailureSpike alert. */ +export function recordCrawlFailure(sourceId: string): void { + counter("crawl.failures", 1, { sourceId }); +} + /** Chunks produced + embedded in a pipeline pass. */ export function recordChunksProcessed(count: number): void { counter("chunks.processed", count); @@ -67,20 +108,59 @@ export function recordDiffsProcessed(count: number): void { counter("diffs.processed", count); } +/** The change score of one non-baseline diff (0–1), as a distribution. */ +export function recordChangeScore(score: number): void { + distribution( + "change_score", + score, + undefined, + [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], + ); +} + /** Alerts dispatched to Slack. */ export function recordAlertFired(count = 1): void { counter("alerts.fired", count); } +/** A failed Slack alert send — backs the AlertSendFailure alert. */ +export function recordAlertSendFailure(sourceId: string): void { + counter("alert.send_failures", 1, { sourceId }); +} + +/** A pgvector store error — backs the PgVectorUnreachable alert. */ +export function recordPgVectorError(): void { + counter("pgvector.errors"); +} + export type TokenKind = "input" | "output" | "cache_read" | "cache_write"; -/** Bedrock token usage, dimensioned by kind so cache effectiveness is visible. */ +/** + * Bedrock token usage. Each kind is its own metric name + * (`bedrock.input_tokens` → `competitive_intelligence_bedrock_input_tokens_total`) + * so the dashboard's per-kind panels — including cache effectiveness — render. + */ export function recordBedrockTokens(kind: TokenKind, count: number): void { if (count <= 0) return; - counter("bedrock.tokens", count, { kind }); + counter(`bedrock.${kind}_tokens`, count); } -/** A circuit breaker tripping open, dimensioned by breaker name. */ -export function recordCircuitBreakerTrip(name: string): void { - counter("circuit_breaker.trips", 1, { breaker: name }); +// ─── Circuit-breaker open gauge ─── +// The CircuitBreakerOpen alert + dashboard panel query a 1/0 gauge labelled by +// `target`. An ObservableGauge reports current per-breaker state on each scrape; +// breakers call setCircuitBreakerOpen on every trip/reset. + +const breakerOpen = new Map(); +let breakerGauge: ObservableGauge | undefined; + +export function setCircuitBreakerOpen(name: string, open: boolean): void { + breakerOpen.set(name, open ? 1 : 0); + if (!breakerGauge) { + breakerGauge = otelMetrics.getMeter(METER_NAME).createObservableGauge("circuit_breaker.open"); + breakerGauge.addCallback((result: ObservableResult) => { + for (const [target, value] of breakerOpen) { + result.observe(value, { target }); + } + }); + } } diff --git a/src/otel.ts b/src/otel.ts deleted file mode 100644 index 5962f8f..0000000 --- a/src/otel.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * OpenTelemetry bootstrap. - * - * `startTelemetry()` initializes the OTel Node SDK — OTLP http/protobuf - * trace + metric exporters plus the Node auto-instrumentations - * (http/fetch/aws-sdk/pg/etc.). Call it FIRST in `main()`, before any - * client is constructed, so the instrumentation hooks are in place before - * the modules they patch get used. - * - * The OTLP target comes from `OTEL_EXPORTER_OTLP_ENDPOINT` (the chart points - * it at the cluster collector, `otel-collector.observability.svc.cluster.local:4318`). - * Resource attributes — `service.name`, `deployment.environment`, - * `agents.tenant`, `agents.platform` — ride in via `OTEL_RESOURCE_ATTRIBUTES`, - * which the SDK reads automatically; we don't hardcode them here. The only - * default we set is a sane `service.name` so spans are attributable even - * without the chart env (local runs). Anything in `OTEL_RESOURCE_ATTRIBUTES` - * wins over the default. - * - * `OTEL_SDK_DISABLED=true` short-circuits the whole thing — used by tests, - * CI, and local runs where no collector is reachable. Traces/metrics simply - * don't export; the OTel API degrades to a no-op (see `metrics.ts`). - */ -import { NodeSDK, metrics as nodeMetrics } from "@opentelemetry/sdk-node"; -import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"; -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; -import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; -import { resourceFromAttributes } from "@opentelemetry/resources"; -import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; -import { logger } from "./logger.js"; - -let sdk: NodeSDK | undefined; - -/** - * Initialize the OTel SDK. Idempotent — a second call is a no-op. Returns - * the SDK instance (or `undefined` when disabled) so callers can drive - * `shutdown()` on graceful exit. - */ -export function startTelemetry(): NodeSDK | undefined { - if (process.env.OTEL_SDK_DISABLED === "true") { - return undefined; - } - if (sdk) { - return sdk; - } - - const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318"; - - sdk = new NodeSDK({ - // service.name default only; deployment.environment / agents.tenant / - // agents.platform come from OTEL_RESOURCE_ATTRIBUTES, merged by the SDK. - resource: resourceFromAttributes({ - [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? "competitive-intelligence", - }), - traceExporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }), - metricReader: new nodeMetrics.PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }), - exportIntervalMillis: 30_000, - }), - instrumentations: [ - getNodeAutoInstrumentations({ - // fs spans are noise for a long-lived crawler — drop them. - "@opentelemetry/instrumentation-fs": { enabled: false }, - }), - ], - }); - - sdk.start(); - logger.info("telemetry started", { endpoint }); - return sdk; -} - -/** - * Flush + stop the SDK. Safe to call when telemetry was never started. - */ -export async function stopTelemetry(): Promise { - if (!sdk) return; - try { - await sdk.shutdown(); - } catch { - // best-effort on shutdown — never block process exit on a flush failure - } finally { - sdk = undefined; - } -} diff --git a/src/pipeline/chunker.test.ts b/src/pipeline/chunker.test.ts index fe929a2..e6b6484 100644 --- a/src/pipeline/chunker.test.ts +++ b/src/pipeline/chunker.test.ts @@ -43,9 +43,17 @@ describe("chunkText", () => { it("applies overlap between chunks", () => { const text = "AAAA\n\nBBBB\n\nCCCC"; const chunks = chunkText(text, { ...opts, maxChunkSize: 10, overlap: 4 }); - if (chunks.length > 1) { - // Second chunk should start with tail of first chunk - expect(chunks[1].text.length).toBeGreaterThan(0); - } + // Segments split to ["AAAA\n\nBBBB", "CCCC"]; chunk[1] is prefixed with the + // last `overlap` (4) chars of the previous segment ("BBBB"). + expect(chunks).toHaveLength(2); + expect(chunks[1].text).toBe("BBBBCCCC"); + expect(chunks[1].text.startsWith("BBBB")).toBe(true); + }); + + it("omits overlap when overlap is 0", () => { + const text = "AAAA\n\nBBBB\n\nCCCC"; + const chunks = chunkText(text, { ...opts, maxChunkSize: 10, overlap: 0 }); + expect(chunks).toHaveLength(2); + expect(chunks[1].text).toBe("CCCC"); }); }); diff --git a/src/pipeline/differ.test.ts b/src/pipeline/differ.test.ts index bc27130..4b99243 100644 --- a/src/pipeline/differ.test.ts +++ b/src/pipeline/differ.test.ts @@ -81,4 +81,21 @@ describe("semanticDiff", () => { expect(result.changeScore).toBe(0); expect(result.totalChunks).toBe(0); }); + + it("treats a score exactly at the threshold as unchanged", async () => { + const chunks = [makeChunk("a:0", "hello")]; + const embeddings = [[1, 0]]; + + // score === threshold → unchanged (the comparison is `score < threshold`) + const atThreshold = makeStore([{ id: "a:0", content: "hello", score: 0.85, metadata: {} }]); + const r1 = await semanticDiff(chunks, embeddings, atThreshold, { competitor: "acme" }); + expect(r1.unchangedChunks).toHaveLength(1); + expect(r1.newChunks).toHaveLength(0); + + // just below → new + const justBelow = makeStore([{ id: "a:0", content: "hello", score: 0.849, metadata: {} }]); + const r2 = await semanticDiff(chunks, embeddings, justBelow, { competitor: "acme" }); + expect(r2.newChunks).toHaveLength(1); + expect(r2.unchangedChunks).toHaveLength(0); + }); }); diff --git a/src/pipeline/index.ts b/src/pipeline/index.ts index ac4c79c..64a5ab5 100644 --- a/src/pipeline/index.ts +++ b/src/pipeline/index.ts @@ -4,6 +4,7 @@ import type { VectorStore, VectorDocument } from "../providers/vectors.js"; import { chunkText } from "./chunker.js"; import { semanticDiff, type DiffResult } from "./differ.js"; import { logger } from "../logger.js"; +import { recordChunksProcessed, recordChangeScore, recordDiffsProcessed } from "../metrics.js"; export interface PipelineResult { diffs: DiffResult[]; @@ -81,14 +82,16 @@ export async function ingestAndDiff( diff = await semanticDiff(chunks, embeddings, store, { competitor: page.competitor, }); + recordChangeScore(diff.changeScore); } diffs.push(diff); - // 4. Replace all stored vectors for this source. deleteByMetadata removes - // every chunk matching the sourceId, preventing stale orphans when a page - // shrinks (e.g., 10 chunks → 6 — the old 6-9 are cleaned up). - await store.deleteByMetadata({ sourceId: page.sourceId }); - + // 4. Replace this source's stored vectors, ordered so a mid-write failure + // can never wipe the source's history (which the cold-start guard would + // silently re-baseline, dropping a real change). Upsert the new chunks + // FIRST — idempotent on chunk IDs — then prune only the stale chunks + // (those not in the new set). A failed upsert throws with the old chunks + // intact; a failed prune leaves harmless duplicates the next crawl cleans. const docs: VectorDocument[] = chunks.map((chunk, i) => ({ id: chunk.id, content: chunk.text, @@ -96,9 +99,15 @@ export async function ingestAndDiff( metadata: chunk.metadata, })); await store.upsert(docs); + await store.deleteByMetadata( + { sourceId: page.sourceId }, + docs.map((d) => d.id), + ); totalChunksStored += docs.length; + recordChunksProcessed(docs.length); } + recordDiffsProcessed(diffs.length); logger.info("pipeline complete", { pages: pages.length, diffs: diffs.length, diff --git a/src/providers/embeddings.ts b/src/providers/embeddings.ts index f36abf8..8155e48 100644 --- a/src/providers/embeddings.ts +++ b/src/providers/embeddings.ts @@ -4,6 +4,15 @@ import { createRegistry } from "./registry.js"; import type { Config } from "../config.js"; import { CircuitBreaker } from "../resilience/circuit-breaker.js"; +// Hard deadlines for every embedding call — embeddings run sequentially on the +// single-writer crawl path, so a hung call stalls the whole crawl. See llm.ts. +const EMBED_TIMEOUT_MS = 30_000; +const EMBED_CONNECT_TIMEOUT_MS = 5_000; + +// OpenAI's embeddings endpoint caps array length + total tokens per request, so +// a large page must be sent in windows rather than one call. +const OPENAI_EMBED_BATCH = 256; + export interface EmbeddingProvider { embed(texts: string[]): Promise; dimensions: number; @@ -20,7 +29,14 @@ class BedrockEmbeddingProvider implements EmbeddingProvider { readonly dimensions: number; constructor(region: string, modelId: string, dimensions: number) { - this.client = new BedrockRuntimeClient({ region }); + this.client = new BedrockRuntimeClient({ + region, + maxAttempts: 3, + requestHandler: { + connectionTimeout: EMBED_CONNECT_TIMEOUT_MS, + requestTimeout: EMBED_TIMEOUT_MS, + }, + }); this.modelId = modelId; this.dimensions = dimensions; } @@ -41,6 +57,7 @@ class BedrockEmbeddingProvider implements EmbeddingProvider { normalize: true, }), }), + { abortSignal: AbortSignal.timeout(EMBED_TIMEOUT_MS) }, ); const body = JSON.parse(new TextDecoder().decode(response.body)); return body.embedding as number[]; @@ -60,20 +77,28 @@ class OpenAIEmbeddingProvider implements EmbeddingProvider { private model: string; constructor(apiKey: string, model: string, dimensions: number) { - this.client = new OpenAI({ apiKey }); + this.client = new OpenAI({ apiKey, timeout: EMBED_TIMEOUT_MS, maxRetries: 2 }); this.model = model; this.dimensions = dimensions; } async embed(texts: string[]): Promise { - return this.breaker.execute(async () => { - const response = await this.client.embeddings.create({ - model: this.model, - input: texts, - dimensions: this.dimensions, + // Window the inputs — a single large page can exceed OpenAI's per-request + // array/token caps, which would 400 the whole crawl's embedding. + const all: number[][] = []; + for (let i = 0; i < texts.length; i += OPENAI_EMBED_BATCH) { + const window = texts.slice(i, i + OPENAI_EMBED_BATCH); + const batch = await this.breaker.execute(async () => { + const response = await this.client.embeddings.create({ + model: this.model, + input: window, + dimensions: this.dimensions, + }); + return response.data.sort((a, b) => a.index - b.index).map((d) => d.embedding); }); - return response.data.sort((a, b) => a.index - b.index).map((d) => d.embedding); - }); + all.push(...batch); + } + return all; } } diff --git a/src/providers/llm.ts b/src/providers/llm.ts index bd99725..6b5f4cd 100644 --- a/src/providers/llm.ts +++ b/src/providers/llm.ts @@ -3,8 +3,16 @@ import OpenAI from "openai"; import { BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime"; import { createRegistry } from "./registry.js"; import { CircuitBreaker } from "../resilience/circuit-breaker.js"; +import { recordBedrockTokens } from "../metrics.js"; import type { Config } from "../config.js"; +// Hard deadlines for every external LLM call. Without these, a hung socket +// blocks the single-writer crawl indefinitely (the circuit breaker only counts +// failures *after* a call returns — it cannot abort one in flight). Generous +// enough for a 4096-token completion; tunable later via config if needed. +const LLM_TIMEOUT_MS = 60_000; +const LLM_CONNECT_TIMEOUT_MS = 5_000; + export interface LlmResponse { text: string; inputTokens: number; @@ -27,7 +35,14 @@ class BedrockLlmProvider implements LlmProvider { private modelId: string; constructor(region: string, modelId: string) { - this.client = new BedrockRuntimeClient({ region }); + this.client = new BedrockRuntimeClient({ + region, + maxAttempts: 3, + requestHandler: { + connectionTimeout: LLM_CONNECT_TIMEOUT_MS, + requestTimeout: LLM_TIMEOUT_MS, + }, + }); this.modelId = modelId; } @@ -44,6 +59,10 @@ class BedrockLlmProvider implements LlmProvider { messages: [{ role: "user", content: [{ text: userMessage }] }], inferenceConfig: { maxTokens: 4096 }, }), + // Hard deadline at the SDK middleware layer — guarantees the call + // aborts even if the underlying socket hangs, regardless of the http + // handler's timeout semantics. + { abortSignal: AbortSignal.timeout(LLM_TIMEOUT_MS) }, ); const text = @@ -52,13 +71,22 @@ class BedrockLlmProvider implements LlmProvider { .map((b) => b.text) .join("") ?? ""; - return { + const result: LlmResponse = { text, inputTokens: response.usage?.inputTokens ?? 0, outputTokens: response.usage?.outputTokens ?? 0, cacheReadTokens: response.usage?.cacheReadInputTokens ?? 0, cacheWriteTokens: response.usage?.cacheWriteInputTokens ?? 0, }; + + // Emit token usage by kind so the Grafana cache-effectiveness panels + // render and ARCHITECTURE's "measured, not assumed" claim holds. + recordBedrockTokens("input", result.inputTokens); + recordBedrockTokens("output", result.outputTokens); + recordBedrockTokens("cache_read", result.cacheReadTokens); + recordBedrockTokens("cache_write", result.cacheWriteTokens); + + return result; }); } } @@ -68,15 +96,17 @@ class BedrockLlmProvider implements LlmProvider { class AnthropicProvider implements LlmProvider { private client: Anthropic; private breaker = new CircuitBreaker("anthropic-llm", { failureThreshold: 3 }); + private model: string; - constructor(apiKey: string) { - this.client = new Anthropic({ apiKey }); + constructor(apiKey: string, model: string) { + this.client = new Anthropic({ apiKey, timeout: LLM_TIMEOUT_MS, maxRetries: 2 }); + this.model = model; } async chat(system: string, userMessage: string): Promise { return this.breaker.execute(async () => { const response = await this.client.messages.create({ - model: "claude-sonnet-4-20250514", + model: this.model, max_tokens: 4096, system, messages: [{ role: "user", content: userMessage }], @@ -103,15 +133,17 @@ class AnthropicProvider implements LlmProvider { class OpenAILlmProvider implements LlmProvider { private client: OpenAI; private breaker = new CircuitBreaker("openai-llm", { failureThreshold: 3 }); + private model: string; - constructor(apiKey: string) { - this.client = new OpenAI({ apiKey }); + constructor(apiKey: string, model: string) { + this.client = new OpenAI({ apiKey, timeout: LLM_TIMEOUT_MS, maxRetries: 2 }); + this.model = model; } async chat(system: string, userMessage: string): Promise { return this.breaker.execute(async () => { const response = await this.client.chat.completions.create({ - model: "gpt-4o", + model: this.model, max_tokens: 4096, messages: [ { role: "system", content: system }, @@ -138,10 +170,16 @@ export function bootstrapLlm(config: Config): LlmProvider { () => new BedrockLlmProvider(config.awsRegion, config.bedrockLlmModel), ); if (config.anthropicApiKey) { - llmRegistry.register("anthropic", () => new AnthropicProvider(config.anthropicApiKey!)); + llmRegistry.register( + "anthropic", + () => new AnthropicProvider(config.anthropicApiKey!, config.anthropicLlmModel), + ); } if (config.openaiApiKey) { - llmRegistry.register("openai", () => new OpenAILlmProvider(config.openaiApiKey!)); + llmRegistry.register( + "openai", + () => new OpenAILlmProvider(config.openaiApiKey!, config.openaiLlmModel), + ); } return llmRegistry.get(config.llmProvider); } diff --git a/src/providers/vectors.test.ts b/src/providers/vectors.test.ts index bfe33b1..4fbb9d1 100644 --- a/src/providers/vectors.test.ts +++ b/src/providers/vectors.test.ts @@ -64,6 +64,20 @@ describe("MemoryVectorStore", () => { expect(await store.count()).toBe(1); }); + it("deleteByMetadata keeps the excluded ids (upsert-then-prune)", async () => { + const store = bootstrapVectorStore(makeConfig()); + await store.upsert([ + { id: "x:0", content: "a", embedding: [1, 0, 0], metadata: { sourceId: "x" } }, + { id: "x:1", content: "b", embedding: [0, 1, 0], metadata: { sourceId: "x" } }, + { id: "x:2", content: "c", embedding: [0, 0, 1], metadata: { sourceId: "x" } }, + ]); + + // Prune everything for the source except the two "new" chunks. + const deleted = await store.deleteByMetadata({ sourceId: "x" }, ["x:0", "x:1"]); + expect(deleted).toBe(1); + expect(await store.count({ sourceId: "x" })).toBe(2); + }); + it("delete removes by ID", async () => { const store = bootstrapVectorStore(makeConfig()); await store.upsert([ @@ -108,14 +122,21 @@ function fakeQuery(responses: PgQueryResult[]): { const ddl = (): PgQueryResult => ({ rows: [], rowCount: 0 }); +// The schema bootstrap runs 5 statements in order: CREATE EXTENSION, CREATE +// TABLE, the atttypmod dimension check (returns the configured dim so the drift +// guard passes), CREATE INDEX (hnsw), CREATE INDEX (gin). +const schema = (dim: number): PgQueryResult[] => [ + ddl(), + ddl(), + { rows: [{ atttypmod: dim }], rowCount: 1 }, + ddl(), + ddl(), +]; + describe("PgVectorStore", () => { it("runs idempotent schema DDL once on first use, then reuses it", async () => { - // 4 DDL statements (extension, table, ivfflat index, gin index) + the op. const { port, spy } = fakeQuery([ - ddl(), - ddl(), - ddl(), - ddl(), + ...schema(3), { rows: [{ n: 0 }], rowCount: 1 }, { rows: [{ n: 0 }], rowCount: 1 }, ]); @@ -126,12 +147,12 @@ describe("PgVectorStore", () => { const sqls = spy.mock.calls.map((c) => c[0] as string); expect(sqls.filter((s) => s.includes("CREATE EXTENSION IF NOT EXISTS vector"))).toHaveLength(1); - expect(sqls.some((s) => s.includes("USING ivfflat (embedding vector_cosine_ops)"))).toBe(true); + expect(sqls.some((s) => s.includes("USING hnsw (embedding vector_cosine_ops)"))).toBe(true); expect(sqls.some((s) => s.includes("USING GIN (metadata)"))).toBe(true); }); it("upserts with a vector literal and jsonb metadata", async () => { - const { port, spy } = fakeQuery([ddl(), ddl(), ddl(), ddl(), ddl()]); + const { port, spy } = fakeQuery([...schema(3), ddl()]); const store = new PgVectorStore({ query: port, embeddingDim: 3 }); await store.upsert([ @@ -148,10 +169,7 @@ describe("PgVectorStore", () => { it("searches by cosine distance and maps score = 1 - distance", async () => { const { port, spy } = fakeQuery([ - ddl(), - ddl(), - ddl(), - ddl(), + ...schema(3), { rows: [{ id: "a:0", content: "hello", metadata: { sourceId: "a" }, score: 0.92 }], rowCount: 1, @@ -176,13 +194,7 @@ describe("PgVectorStore", () => { }); it("count() returns the row count and filters via jsonb containment", async () => { - const { port, spy } = fakeQuery([ - ddl(), - ddl(), - ddl(), - ddl(), - { rows: [{ n: "7" }], rowCount: 1 }, - ]); + const { port, spy } = fakeQuery([...schema(3), { rows: [{ n: "7" }], rowCount: 1 }]); const store = new PgVectorStore({ query: port, embeddingDim: 3 }); const n = await store.count({ sourceId: "a" }); @@ -192,13 +204,32 @@ describe("PgVectorStore", () => { }); it("deleteByMetadata returns the deleted row count", async () => { - const { port } = fakeQuery([ddl(), ddl(), ddl(), ddl(), { rows: [], rowCount: 3 }]); + const { port } = fakeQuery([...schema(3), { rows: [], rowCount: 3 }]); const store = new PgVectorStore({ query: port, embeddingDim: 3 }); expect(await store.deleteByMetadata({ sourceId: "a" })).toBe(3); }); + it("deleteByMetadata excludes keepIds via id != ALL", async () => { + const { port, spy } = fakeQuery([...schema(3), { rows: [], rowCount: 1 }]); + const store = new PgVectorStore({ query: port, embeddingDim: 3 }); + + const deleted = await store.deleteByMetadata({ sourceId: "a" }, ["a:0", "a:1"]); + expect(deleted).toBe(1); + const delCall = spy.mock.calls.find((c) => (c[0] as string).includes("DELETE FROM")); + const [sql, params] = delCall!; + expect(sql as string).toContain("id != ALL($2::text[])"); + expect((params as unknown[])[1]).toEqual(["a:0", "a:1"]); + }); + + it("fails loud when the stored column dim differs from the configured dim", async () => { + // atttypmod reports VECTOR(5) but the store is configured for dim 3. + const { port } = fakeQuery([ddl(), ddl(), { rows: [{ atttypmod: 5 }], rowCount: 1 }]); + const store = new PgVectorStore({ query: port, embeddingDim: 3 }); + await expect(store.count()).rejects.toThrow(/VECTOR\(5\).*configured embeddingDim is 3/); + }); + it("rejects an embedding whose dim does not match the configured dim", async () => { - const { port } = fakeQuery([ddl(), ddl(), ddl(), ddl()]); + const { port } = fakeQuery([...schema(1024)]); const store = new PgVectorStore({ query: port, embeddingDim: 1024 }); await expect(store.search([0.1, 0.2], 5)).rejects.toThrow( /embedding dim 2 does not match configured 1024/, diff --git a/src/providers/vectors.ts b/src/providers/vectors.ts index 6dd2b4f..bf79259 100644 --- a/src/providers/vectors.ts +++ b/src/providers/vectors.ts @@ -1,7 +1,9 @@ +import { readFileSync } from "node:fs"; import { Pool } from "pg"; import type { Config } from "../config.js"; import { createRegistry } from "./registry.js"; import { logger } from "../logger.js"; +import { recordPgVectorError } from "../metrics.js"; export interface VectorDocument { id: string; @@ -25,7 +27,14 @@ export interface VectorStore { filter?: Record, ): Promise; delete(ids: string[]): Promise; - deleteByMetadata(filter: Record): Promise; + /** + * Delete every chunk matching `filter`, optionally excluding ids in + * `keepIds`. The exclusion lets the pipeline upsert-then-prune: write the new + * chunks first, then drop only the stale ones, so a failed write never leaves + * a source with zero history (which the cold-start guard would silently + * re-baseline). Returns the number of rows deleted. + */ + deleteByMetadata(filter: Record, keepIds?: string[]): Promise; count(filter?: Record): Promise; } @@ -81,9 +90,11 @@ class MemoryVectorStore implements VectorStore { for (const id of ids) this.docs.delete(id); } - async deleteByMetadata(filter: Record): Promise { + async deleteByMetadata(filter: Record, keepIds?: string[]): Promise { + const keep = keepIds ? new Set(keepIds) : undefined; let deleted = 0; for (const [id, doc] of this.docs) { + if (keep?.has(id)) continue; if (matchesFilter(doc.metadata, filter)) { this.docs.delete(id); deleted++; @@ -183,12 +194,27 @@ export class PgVectorStore implements VectorStore { metadata JSONB NOT NULL DEFAULT '{}'::jsonb )`, ); - // IVFFlat over cosine distance — good recall/build-cost balance for - // <~1M rows. For larger sets switch to HNSW (`USING hnsw`). + // Fail loud on a dimension mismatch. CREATE TABLE IF NOT EXISTS silently + // no-ops against an existing table, so an EMBEDDING_DIMENSIONS change would + // otherwise surface only as a confusing late insert failure. atttypmod on a + // pgvector column is its configured dimension. + const { rows } = await this.query.query<{ atttypmod: number }>( + `SELECT atttypmod FROM pg_attribute + WHERE attrelid = $1::regclass AND attname = 'embedding'`, + [this.table], + ); + const existingDim = rows[0]?.atttypmod; + if (typeof existingDim === "number" && existingDim > 0 && existingDim !== this.embeddingDim) { + throw new Error( + `pgvector: table ${this.table} has VECTOR(${existingDim}) but configured embeddingDim is ${this.embeddingDim} — drop/migrate the table or fix EMBEDDING_DIMENSIONS`, + ); + } + // HNSW over cosine distance — builds incrementally and needs no populated + // table, so it avoids IVFFlat's empty-table centroid pitfall (lists are + // meaningless with zero rows). Fits the small, slowly-growing source set. await this.query.query( `CREATE INDEX IF NOT EXISTS ${this.table}_embedding_idx - ON ${this.table} USING ivfflat (embedding vector_cosine_ops) - WITH (lists = 100)`, + ON ${this.table} USING hnsw (embedding vector_cosine_ops)`, ); // GIN over the metadata jsonb so `@>` containment filters stay on index. await this.query.query( @@ -259,8 +285,16 @@ export class PgVectorStore implements VectorStore { await this.query.query(`DELETE FROM ${this.table} WHERE id = ANY($1::text[])`, [ids]); } - async deleteByMetadata(filter: Record): Promise { + async deleteByMetadata(filter: Record, keepIds?: string[]): Promise { await this.ensureSchema(); + if (keepIds && keepIds.length > 0) { + const { rowCount } = await this.query.query( + `DELETE FROM ${this.table} + WHERE metadata @> $1::jsonb AND id != ALL($2::text[])`, + [JSON.stringify(filter), keepIds], + ); + return rowCount ?? 0; + } const { rowCount } = await this.query.query( `DELETE FROM ${this.table} WHERE metadata @> $1::jsonb`, [JSON.stringify(filter)], @@ -294,15 +328,40 @@ export function bootstrapVectorStore(config: Config): VectorStore { // `pg` reads PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE from the // environment automatically when no connectionString is given, so an // unset DATABASE_URL still works against the Aurora-managed credentials. - // RDS/Aurora enforce TLS; `rejectUnauthorized: false` keeps the - // connection encrypted without bundling the RDS CA chain. + // TLS is verified: with a mounted RDS CA bundle (PG_CA_PATH) we pin to it; + // otherwise Node's built-in trust store validates the publicly-anchored RDS + // global CA. Either way the connection is encrypted AND authenticated. + const ssl = config.pgCaPath + ? { ca: readFileSync(config.pgCaPath, "utf8"), rejectUnauthorized: true } + : { rejectUnauthorized: true }; const pool = new Pool({ ...(config.databaseUrl ? { connectionString: config.databaseUrl } : {}), max: 5, - ssl: { rejectUnauthorized: false }, + ssl, + // Bound every phase so a hung DB can't stall the single-writer crawl. + connectionTimeoutMillis: 5_000, + statement_timeout: 15_000, + query_timeout: 15_000, + idle_in_transaction_session_timeout: 30_000, + }); + pool.on("error", (err: Error) => { + recordPgVectorError(); + logger.error("pgvector pool error", { error: err.message }); }); - pool.on("error", (err: Error) => logger.error("pgvector pool error", { error: err.message })); - return new PgVectorStore({ query: pool, embeddingDim: config.embeddingDimensions }); + // Count query-level failures too (Aurora failover, statement timeout) so the + // PgVectorUnreachable alert fires on more than just idle-client errors. + const query: PgQueryPort = { + async query(text: string, values?: unknown[]): Promise> { + try { + const result = await pool.query(text, values); + return { rows: result.rows as T[], rowCount: result.rowCount }; + } catch (err) { + recordPgVectorError(); + throw err; + } + }, + }; + return new PgVectorStore({ query, embeddingDim: config.embeddingDimensions }); }); return vectorRegistry.get(config.vectorProvider); } diff --git a/src/resilience/circuit-breaker.ts b/src/resilience/circuit-breaker.ts index 06de322..1da1d2c 100644 --- a/src/resilience/circuit-breaker.ts +++ b/src/resilience/circuit-breaker.ts @@ -7,6 +7,8 @@ * all failures since the last reset count equally regardless of age. */ +import { setCircuitBreakerOpen } from "../metrics.js"; + type State = "closed" | "open" | "half-open"; export interface CircuitBreakerOptions { @@ -70,11 +72,13 @@ export class CircuitBreaker { private trip(): void { this.state = "open"; + setCircuitBreakerOpen(this.name, true); } private reset(): void { this.state = "closed"; this.failures = 0; this.halfOpenAttempts = 0; + setCircuitBreakerOpen(this.name, false); } } diff --git a/src/scheduler/index.ts b/src/scheduler/index.ts index eecbf38..9efc5d6 100644 --- a/src/scheduler/index.ts +++ b/src/scheduler/index.ts @@ -1,4 +1,4 @@ -import { logger } from "../logger.js"; +import { logger, toMessage } from "../logger.js"; export interface ScheduledJob { name: string; @@ -40,7 +40,7 @@ export function createScheduler(jobs: ScheduledJob[]): Scheduler { logger.error("job failed", { name: job.name, durationMs: Date.now() - start, - error: err instanceof Error ? err.message : String(err), + error: toMessage(err), }); } }, job.intervalMs); diff --git a/src/slack/commands.ts b/src/slack/commands.ts index 858f227..390f1b4 100644 --- a/src/slack/commands.ts +++ b/src/slack/commands.ts @@ -1,6 +1,6 @@ import type { App } from "@slack/bolt"; import type { IntelEngine } from "../intel/index.js"; -import { logger } from "../logger.js"; +import { logger, toMessage } from "../logger.js"; /** * Register /competitive-intelligence slash commands. @@ -12,7 +12,7 @@ import { logger } from "../logger.js"; export function registerCommands( app: App, intel: IntelEngine, - runCrawl: () => Promise, + runCrawl: () => Promise<"ran" | "skipped">, ): void { app.command("/competitive-intelligence", async ({ command, ack, respond }) => { await ack(); @@ -35,9 +35,7 @@ export function registerCommands( const answer = await intel.query(body); await respond(answer); } catch (err) { - logger.error("slash query failed", { - error: err instanceof Error ? err.message : String(err), - }); + logger.error("slash query failed", { error: toMessage(err) }); await respond("Query failed. Check the logs."); } break; @@ -48,12 +46,14 @@ export function registerCommands( await respond(":satellite: Starting crawl..."); try { - await runCrawl(); - await respond(":white_check_mark: Crawl complete."); + const outcome = await runCrawl(); + await respond( + outcome === "skipped" + ? ":hourglass_flowing_sand: A crawl is already running (or no sources are configured) — skipped." + : ":white_check_mark: Crawl complete.", + ); } catch (err) { - logger.error("slash crawl failed", { - error: err instanceof Error ? err.message : String(err), - }); + logger.error("slash crawl failed", { error: toMessage(err) }); await respond(":x: Crawl failed. Check the logs."); } break; diff --git a/src/slack/handlers.ts b/src/slack/handlers.ts index a50f1b5..d70056d 100644 --- a/src/slack/handlers.ts +++ b/src/slack/handlers.ts @@ -1,6 +1,6 @@ import type { App } from "@slack/bolt"; import type { IntelEngine } from "../intel/index.js"; -import { logger } from "../logger.js"; +import { logger, toMessage } from "../logger.js"; /** * Register Slack event handlers. @@ -37,7 +37,7 @@ export function registerHandlers(app: App, intel: IntelEngine): void { thread_ts: event.ts, }); } catch (err) { - logger.error("query failed", { error: err instanceof Error ? err.message : String(err) }); + logger.error("query failed", { error: toMessage(err) }); await say({ text: "Something went wrong processing that query. Check the logs for details.", thread_ts: event.ts, @@ -45,9 +45,14 @@ export function registerHandlers(app: App, intel: IntelEngine): void { } }); - // Handle direct messages + // Handle direct messages ONLY. Bolt's generic message listener fires for + // every message in every channel the bot can read; without this gate, a busy + // channel would trigger an embedding + LLM call (and a reply) on every + // message — unbounded Bedrock spend and channel noise. Channel queries go + // through the app_mention handler above. app.message(async ({ message, say }) => { if (message.subtype) return; // skip bot messages, edits, etc. + if (message.channel_type !== "im") return; if (!("text" in message) || !message.text) return; const question = message.text.trim(); @@ -59,7 +64,7 @@ export function registerHandlers(app: App, intel: IntelEngine): void { const answer = await intel.query(question); await say({ text: answer }); } catch (err) { - logger.error("DM query failed", { error: err instanceof Error ? err.message : String(err) }); + logger.error("DM query failed", { error: toMessage(err) }); await say({ text: "Something went wrong. Check the logs." }); } }); diff --git a/src/slack/index.ts b/src/slack/index.ts index 2fe1599..1d01775 100644 --- a/src/slack/index.ts +++ b/src/slack/index.ts @@ -7,6 +7,11 @@ import type { SlackBlocks } from "../alerts/formatter.js"; import { registerHandlers } from "./handlers.js"; import { registerCommands } from "./commands.js"; import { logger } from "../logger.js"; +import { CircuitBreaker } from "../resilience/circuit-breaker.js"; + +// Bound the Slack API call so a hung postMessage can't stall the crawl loop +// (the sink is awaited inside the single-writer crawl mutex). +const SLACK_TIMEOUT_MS = 10_000; export interface SlackBot { app: App; @@ -18,28 +23,34 @@ export interface SlackBot { export function createSlackBot( config: Config, intel: IntelEngine, - runCrawl: () => Promise, + runCrawl: () => Promise<"ran" | "skipped">, ): SlackBot { const app = new App({ token: config.slackBotToken, signingSecret: config.slackSigningSecret, appToken: config.slackAppToken, socketMode: !!config.slackAppToken, + clientOptions: { timeout: SLACK_TIMEOUT_MS }, }); // Wire up event handlers and slash commands registerHandlers(app, intel); registerCommands(app, intel, runCrawl); - // Alert sink that posts to Slack channels + // Alert sink that posts to Slack channels — wrapped in a circuit breaker like + // every other external call, so a degraded Slack endpoint fails fast instead + // of stalling the crawl loop on every diff. + const breaker = new CircuitBreaker("slack-alerts", { failureThreshold: 3 }); const sink: AlertSink = { async send(channel: string, message: SlackBlocks) { - await app.client.chat.postMessage({ - token: config.slackBotToken, - channel, - text: message.text, - blocks: message.blocks as KnownBlock[], - }); + await breaker.execute(() => + app.client.chat.postMessage({ + token: config.slackBotToken, + channel, + text: message.text, + blocks: message.blocks as KnownBlock[], + }), + ); }, }; @@ -51,7 +62,15 @@ export function createSlackBot( await app.start(); logger.info("slack bot started (socket mode)"); } else { - logger.info("slack bot started (http mode)", { port: config.port }); + // HTTP mode: the Bolt receiver must actually listen, or no Slack events + // arrive. Bind it one port above the health server (which owns + // config.port) so the two HTTP servers don't collide. + const eventsPort = config.port + 1; + await app.start(eventsPort); + logger.info("slack bot started (http mode)", { + eventsPort, + healthPort: config.port, + }); } }, async stop() { diff --git a/vitest.config.ts b/vitest.config.ts index be92b9a..e5a4668 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,15 @@ export default defineConfig({ provider: "v8", include: ["src/**/*.ts"], exclude: ["src/**/*.test.ts"], + // Floors set just below current measured coverage so CI fails on a + // regression, not on the current state. Ratchet up as the orchestrators + // (crawler/slack/intel) gain tests. + thresholds: { + lines: 30, + functions: 28, + branches: 38, + statements: 30, + }, }, }, });