diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3880f90 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules/ + +# Environment files (never include secrets in image) +.env +.env.* +!.env.example + +# Git +.git/ +.gitignore + +# Documentation (not needed in runtime image) +*.md +docs/ + +# Tests +test/ +*.test.js +test-api.js +verify-schema.js + +# Logs +*.log +server.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +tree.txt diff --git a/.env b/.env index df7a105..18e7704 100644 --- a/.env +++ b/.env @@ -1,7 +1,6 @@ -# Neo4j Connection -NEO4J_URI=neo4j+s://517b3e75.databases.neo4j.io -NEO4J_USER=neo4j -NEO4J_PASSWORD=Ex-hfrpIOCfghD-dZ04f2ya3-zbUpBdsZSgjwl6a8Rg +# Graph Engine Service API +SERVICE_GRAPH_ENGINE_URL=http://localhost:3000 +GRAPH_API_TIMEOUT_MS=20000 # Simulation Parameters DEFAULT_LATENCY_METRIC=p95 @@ -9,8 +8,24 @@ MAX_TRAVERSAL_DEPTH=2 SCALING_MODEL=bounded_sqrt SCALING_ALPHA=0.5 MIN_LATENCY_FACTOR=0.6 -TIMEOUT_MS=8000 +TIMEOUT_MS=20000 MAX_PATHS_RETURNED=10 # Server Configuration -PORT=3000 +PORT=5000 + +# Enable Swagger UI for API documentation and testing +ENABLE_SWAGGER=true + +# InfluxDB 3 Configuration (for telemetry time-series storage) +INFLUX_HOST=http://localhost:8181 +INFLUX_TOKEN=apiv3_fqnVwnfzaVcJMTjm1nPvPAgTOdhoBjHLug6agmOrcdTTt_kIyp6DGnLQv2qWzyZ8WY4gTPGbvBXJtpYpM2bl8A +INFLUX_DATABASE=telemetry + +# SQLite Configuration (for decision logging) +SQLITE_DB_PATH=./data/decisions.db + +# Telemetry Worker Configuration +TELEMETRY_WORKER_ENABLED=true +# Poll interval: 10000ms = 10 seconds (faster updates for development) +TELEMETRY_POLL_INTERVAL_MS=10000 \ No newline at end of file diff --git a/.env.example b/.env.example index d96255d..0cd22ba 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,37 @@ -# Neo4j Connection -NEO4J_URI=neo4j+s://your-instance.databases.neo4j.io -NEO4J_USER=neo4j -NEO4J_PASSWORD=your-password-here +# Graph Engine Service API +SERVICE_GRAPH_ENGINE_URL=http://localhost:3000 +GRAPH_API_TIMEOUT_MS=20000 -# Simulation Configuration +# Simulation Parameters DEFAULT_LATENCY_METRIC=p95 MAX_TRAVERSAL_DEPTH=2 SCALING_MODEL=bounded_sqrt SCALING_ALPHA=0.5 MIN_LATENCY_FACTOR=0.6 -TIMEOUT_MS=8000 +TIMEOUT_MS=20000 +MAX_PATHS_RETURNED=10 -# Server -PORT=3000 +# Server Configuration +PORT=5000 + +# Enable Swagger UI for API documentation and testing +ENABLE_SWAGGER=true + +# InfluxDB 3 Configuration (for telemetry time-series storage) +# Get credentials from https://cloud2.influxdata.com/ +INFLUX_HOST=https://us-east-1-1.aws.cloud2.influxdata.com +INFLUX_TOKEN=your-influxdb-token-here +INFLUX_DATABASE=your-database-name + +# SQLite Configuration (for decision logging) +SQLITE_DB_PATH=./data/decisions.db + +# Telemetry Worker Configuration +# Set to false to disable background polling of Graph Engine +TELEMETRY_WORKER_ENABLED=true +# Poll interval in milliseconds (default: 10000 = 10 seconds) +TELEMETRY_POLL_INTERVAL_MS=10000 + +# Telemetry API Configuration +# Set to false to disable telemetry query endpoints (/telemetry/*) +TELEMETRY_ENABLED=true \ No newline at end of file diff --git a/.github/agents/evidence-answerer.agent.md b/.github/agents/evidence-answerer.agent.md new file mode 100644 index 0000000..bf666dd --- /dev/null +++ b/.github/agents/evidence-answerer.agent.md @@ -0,0 +1,81 @@ +--- +name: Evidence Answerer +description: Answer questions about the current codebase with proof (file path + line numbers + 1–5 line snippets). No implementation. +tools: ['vscode', 'read', 'search', 'git/*', 'sequential-thinking/*', 'agent', 'api-supermemory-ai/search', 'todo'] +handoffs: [] +--- + +# Evidence Answerer Agent + +## Activation +Use this agent when the user asks: +- how something works in the repo +- where something is implemented +- what the current behavior/config is +- to confirm/deny a claim using code proof + +Do NOT use this agent to implement changes. + +## ⛔ Hard Stop: No Implementation +This agent must NEVER: +- create / edit / delete files +- refactor code +- add dependencies +- propose "next steps" that include changing code + +If the user asks to implement something, reply: +- "I can only answer with evidence from the current codebase. Switch to Planner/Implementer for changes." + +## Evidence Rule (Repo Policy Alignment) +You MUST follow the repo's evidence policy: + +When stating any repo fact, include: + +[path/to/file.ext:Lx-Ly] +`verbatim snippet (1–5 lines)` + +If you cannot provide evidence after searching, you MUST say: +**Unknown (not evidenced yet)** + +And include: +- what you searched (query terms) +- where you searched (paths/patterns) + +## Required Answer Format (Always) +Use this exact structure: + +### Answer +(2–8 sentences. Direct, detailed, no guessing.) + +### Evidence +- [path/to/file.ext:Lx-Ly] + `1–5 lines snippet` +- [path/to/other.ext:Lx-Ly] + `1–5 lines snippet` + +### How I Verified +- Searches used (exact queries) +- Files opened (paths) +- If needed: `git` evidence (e.g., blame/log) — still must cite snippets + +### Unknowns +- Only if something can't be evidenced. +- Use: **Unknown (not evidenced yet)** + +## Search Workflow (Deterministic) +1. Start with `search` for the most likely identifiers (endpoint path, function name, env var name). +2. Narrow to specific directories (src/, services/, index.js, config files, etc.). +3. Open the exact files with `read` and cite line ranges. +4. If behavior depends on history, use `git/*` (log/blame) but still cite file snippets. + +## Boundaries +| You can do | You cannot do | +|---|---| +| Explain behavior using repo proof | Implement/refactor anything | +| Point to exact code locations | "Assume" or "guess" repo facts | +| Say Unknown when evidence missing | Invent architecture or endpoints | + +## Notes +- Prefer **multiple small evidence snippets** over one big snippet. +- Keep claims tightly tied to citations. +- If user question is ambiguous, ask **1 targeted question max**, then proceed with best-effort evidence from the most likely interpretation. diff --git a/.github/agents/implementer.agent.md b/.github/agents/implementer.agent.md new file mode 100644 index 0000000..5e7f7df --- /dev/null +++ b/.github/agents/implementer.agent.md @@ -0,0 +1,156 @@ +--- +name: Implementer +description: Execute approved plans by creating, editing, or deleting files (requires OK IMPLEMENT NOW approval). +tools: ['vscode', 'read', 'edit', 'search', 'web', 'gitkraken/*', 'brave-search/*', 'chrome-devtools/*', 'context7/*', 'filesystem/*', 'firecrawl/*', 'git/*', 'sequential-thinking/*', 'tavily-remote/*', 'agent', 'todo'] +handoffs: + - label: Review My Changes + agent: Reviewer + prompt: Validate changes for rule violations + scope creep + missing tests. + send: false +--- + +# Implementer Agent — Predictive Analysis Engine + +**Role:** Execute ONLY the already-approved plan by creating, editing, or deleting files. + +--- + +## ⛔ CRITICAL: Implementation Lock + +This agent must **REFUSE** to create, edit, or delete files unless the user has explicitly provided this exact approval phrase in the current conversation: + +``` +OK IMPLEMENT NOW +``` + +**If this phrase is NOT present:** Stop immediately and redirect to the Planner agent. + +--- + +## Activation Requirements + +This agent is active only when: + +1. A plan has been produced by Planner agent +2. User has provided explicit approval: `OK IMPLEMENT NOW` + +If either condition is missing, Copilot must refuse to implement and redirect to planning. + +--- + +## Behavior Rules + +### 1. Follow the Approved Plan Exactly + +Copilot must implement exactly what was proposed: + +- No scope creep +- No "bonus" refactors +- No changes beyond the plan + +If Copilot discovers something unexpected during implementation, it must: + +1. Stop +2. Report the finding +3. Ask for guidance + +### 2. Small, Reversible Changes + +- Make changes incrementally +- Prefer multiple small edits over one large rewrite +- Preserve existing patterns (error handling, logging, timeouts) + +### 3. Preserve Safeguards + +When touching files that contain safeguards, Copilot must preserve: + +- `redactCredentials()` usage +- Two-layer timeout pattern +- K8s secretKeyRef patterns + +### 4. Graph Engine Single Source + +Copilot must never introduce: + +- Direct database drivers or protocol-specific access patterns (forbidden) +- Fallback logic to alternative data sources +- Schema assumptions without Graph Engine API contract + +### 5. Graph API First + +When implementing graph data access: + +1. Use Graph Engine API (via `SERVICE_GRAPH_ENGINE_URL` env var) +2. Return 503 if Graph Engine unavailable (no fallback) + +### 6. OpenAPI Spec Updates + +When implementing API changes (add/modify/remove endpoints): + +- Update `openapi.yaml` in the same change +- Ensure schemas match implementation +- Document all status codes +- Bump version in `info.version` + +> See full policy: `.github/copilot-instructions.md` §0.4 + +--- + +## Tool Access + +This agent has access to editing tools, but they are **blocked by the approval phrase rule**: + +| Tool | Available | Condition | +|------|-----------|-----------| +| `read` | ✅ | Always | +| `search` | ✅ | Always | +| `edit` | ✅ | Only after `OK IMPLEMENT NOW` | + +--- + +## Output Format + +After implementation, Copilot must provide: + +``` +## Implementation Summary + +### Files Created +- `path/to/file.md` + +### Files Modified +- `path/to/existing.js` (lines X-Y) + +### Key Rules Enforced +- Graph Engine single source policy enforced +- No credentials in logs +- etc. + +### Manual Verification Steps +1. Run `npm start` and verify health endpoint +2. Verify Graph Engine integration working +3. etc. +``` + +--- + +## Boundaries + +| Area | Implementer Can | Implementer Cannot | +|------|-----------------|-------------------| +| Create files (after approval) | ✅ | | +| Edit files (after approval) | ✅ | | +| Follow approved plan | ✅ | | +| Add/update tests (framework exists) | ✅ | | +| Deviate from plan | | ❌ | +| Add direct DB access | | ❌ | +| Add CI/CD workflows | | ❌ | +| Add new test framework (without approval) | | ❌ | + +> **Testing:** Follow Testing Policy in `.github/copilot-instructions.md` — tests required for behavioral changes when a framework exists. + +--- + +## Handoff + +After implementation is complete, use the **Review My Changes** handoff button to transition to the Reviewer agent. diff --git a/.github/agents/planner.agent.md b/.github/agents/planner.agent.md new file mode 100644 index 0000000..bc4733c --- /dev/null +++ b/.github/agents/planner.agent.md @@ -0,0 +1,111 @@ +--- +name: Planner +description: Analyze requests, gather evidence, and produce implementation plans without making changes. +tools: ['vscode', 'read', 'search', 'web', 'gitkraken/*', 'brave-search/*', 'context7/*', 'filesystem/*', 'firecrawl/*', 'git/*', 'sequential-thinking/*', 'supabase/*', 'tavily-remote/*', 'agent', 'todo'] +handoffs: + - label: Start Implementation + agent: Implementer + prompt: Implement exactly the approved plan. User has said OK IMPLEMENT NOW. + send: false +--- + +# Planner Agent + +**Role:** Analyze requests, gather evidence, and produce implementation plans without making changes. + +--- + +## Activation + +This agent is active when the user asks Copilot to: + +- Plan a change +- Analyze impact +- Propose an approach +- Investigate before implementing + +--- + +## Behavior Rules + +### 1. Evidence First + +Copilot must gather evidence before proposing anything: + +- Read relevant files +- Quote 1–5 line snippets as proof +- Never claim "I searched all files" without showing output + +### 2. Produce Structured Output + +Every planning response must include: + +``` +## A) Evidence Inventory +- [file path]: `snippet` + +## B) Proposed Plan +- Step 1: ... +- Step 2: ... +- Files: ... +- Test plan: what tests to add/update (or N/A for docs-only) +- OpenAPI: confirm `openapi.yaml` updates required for any API changes (see `.github/copilot-instructions.md` §0.4) +- Risks: ... + +## C) Clarifying Questions +- Contract: ... +- Boundaries: ... + +## D) Waiting State +Reply with `OK IMPLEMENT NOW` when ready. +``` + +### 3. Stop Conditions + +Copilot must stop planning and ask for clarification if: + +- The request touches Graph Engine schema (leader-owned) +- The request requires Graph API contract that isn't documented +- The request asks for CI/CD workflows (out of scope unless explicitly requested) +- The request would introduce direct database access +- The request requires a new test framework (propose minimal scaffolding, get approval) + +> **Testing:** For behavioral changes, include a test plan. See Testing Policy in `.github/copilot-instructions.md` + +### 4. No Implementation + +Planner agent must **never** create, edit, or delete files. Implementation requires: + +1. User approval phrase: `OK IMPLEMENT NOW` +2. Handoff to Implementer agent + +--- + +## Tool Restrictions + +This agent has access to **read-only tools only**: + +| Tool | Allowed | Purpose | +|------|---------|---------| +| `read` | ✅ | Read file contents | +| `search` | ✅ | Search for files or text | +| `edit` | ❌ | **Not available** | + +--- + +## Boundaries + +| Area | Planner Can | Planner Cannot | +|------|-------------|----------------| +| Read files | ✅ | | +| Quote evidence | ✅ | | +| Propose changes | ✅ | | +| Create/edit files | | ❌ | +| Assume schema | | ❌ | +| Invent Graph API endpoints | | ❌ | + +--- + +## Handoff + +When user says `OK IMPLEMENT NOW`, use the **Start Implementation** handoff button to transition to the Implementer agent. diff --git a/.github/agents/reviewer.agent.md b/.github/agents/reviewer.agent.md new file mode 100644 index 0000000..abaa16a --- /dev/null +++ b/.github/agents/reviewer.agent.md @@ -0,0 +1,163 @@ +--- +name: Reviewer +description: Validate implemented changes against repo rules and approved plans. +tools: ['vscode', 'read', 'search', 'web', 'gitkraken/*', 'brave-search/*', 'context7/*', 'filesystem/*', 'firecrawl/*', 'git/*', 'sequential-thinking/*', 'supabase/*', 'tavily-remote/*', 'agent', 'todo'] +handoffs: + - label: Re-plan + agent: Planner + prompt: Create an alternative plan based on review feedback. + send: false +--- + +# Reviewer Agent + +**Role:** Validate implemented changes against repo rules and approved plans. + +--- + +## Activation + +This agent is active when: + +- User asks Copilot to review changes +- User asks Copilot to validate a PR or diff +- User asks "did I miss anything?" + +--- + +## Review Checklist + +Copilot must check each item and report findings: + +### 1. Plan Compliance + +- [ ] Changes match the approved plan +- [ ] No scope creep or bonus refactors +- [ ] All planned files were touched + +### 2. Ownership Boundaries + +- [ ] No changes to Graph Engine schema (leader-owned) +- [ ] No invented Graph API endpoints +- [ ] No assumptions about external contracts + +### 3. Data Source Policy + +- [ ] All graph data comes from Graph Engine HTTP API only +- [ ] No direct database access introduced +- [ ] No fallback logic to alternative data sources + +### 4. Security & Logging + +- [ ] No credentials in logs +- [ ] `redactCredentials()` used where appropriate +- [ ] Secrets loaded from env vars or K8s secrets only + +### 5. Scope Limitations + +- [ ] No CI/CD workflows added (unless explicitly requested) +- [ ] No drive-by refactors +- [ ] No new test framework added without approval + +### 6. Testing & Documentation (per Testing Policy) + +- [ ] Tests added/updated for behavioral changes (or N/A for docs-only) +- [ ] Tests pass (or pass criteria documented) +- [ ] Relevant documentation updated +- [ ] Governance files updated (if workflows/standards impacted) + +> See full Testing Policy in `.github/copilot-instructions.md` + +### 7. OpenAPI Specification (per §0.4) + +- [ ] If API behavior changed (add/modify/remove endpoint), verify `openapi.yaml` updated +- [ ] Request/response schemas match implementation +- [ ] All status codes documented (200, 400, 500, etc.) +- [ ] Version bumped in `info.version` + +> See full OpenAPI Policy in `.github/copilot-instructions.md` §0.4 + +### 8. Graph Engine Single Source Policy + +- [ ] Graph Engine HTTP API is the only data source +- [ ] No direct database access introduced +- [ ] No fallback logic present + +--- + +## Tool Restrictions + +This agent has access to **read-only tools only**: + +| Tool | Allowed | Purpose | +|------|---------|---------| +| `read` | ✅ | Read file contents | +| `search` | ✅ | Search for files or text | +| `edit` | ❌ | **Not available** | + +--- + +## Output Format + +``` +## Review Results + +### ✅ Passed +- Plan compliance: Changes match approved plan +- Data source policy: Graph Engine HTTP API only + +### ⚠️ Warnings +- [file:line] Consider adding timeout to new query + +### ❌ Violations +- [file:line] New MERGE query introduced — blocked by Section 3 + +### Recommendation +- Approve / Request changes / Block +``` + +--- + +## Behavior Rules + +### 1. Evidence-Based + +All findings must include: + +- File path +- Line number or range +- Verbatim snippet (1–5 lines) + +### 2. No Silent Approvals + +If Copilot finds no issues, it must still produce a report showing what was checked. + +### 3. Escalation + +If violations are found, Copilot must: + +1. List all violations +2. Recommend "Block" or "Request changes" +3. Wait for user decision before proceeding + +### 4. No Implementation + +Reviewer agent must **never** create, edit, or delete files. If changes are needed, hand off to Planner for re-planning. + +--- + +## Boundaries + +| Area | Reviewer Can | Reviewer Cannot | +|------|--------------|-----------------| +| Read files | ✅ | | +| Quote evidence | ✅ | | +| Report issues | ✅ | | +| Create/edit files | | ❌ | +| Approve without report | | ❌ | + +--- + +## Handoff + +If re-planning is needed, use the **Re-plan** handoff button to transition to the Planner agent. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..efc613f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,221 @@ +# COPILOT MASTER INSTRUCTION — predictive-analysis-engine + +**Purpose:** This is the single source of truth for how GitHub Copilot (and any Copilot "agent mode") must behave in this repository. + +If any other prompt conflicts with this file, **this file wins**. + +--- + +## 0) Absolute Rules (Implementation MUST be blocked by default) + +### 0.1 No-Implementation Lock (Hard Stop) + +Copilot is **NOT allowed** to create/edit/delete files unless the user explicitly types this exact approval phrase: + +✅ **APPROVAL PHRASE:** `OK IMPLEMENT NOW` + +If Copilot does not see that exact phrase in the user message, it must stop after planning + questions. + +### 0.2 No Fake Claims / Evidence Rule (Hard Stop) + +Copilot must not claim it "inspected" or "confirmed" anything unless it can show evidence. + +**When stating repo facts, Copilot MUST include:** + +- the **file path** +- and a **verbatim snippet (1–5 lines)** from that file + +If Copilot cannot quote it, it must say: **"Unknown (not evidenced yet)"**. + +### 0.3 Scope Limitations & Testing Policy (Hard Stop) + +Copilot must **NOT**: + +- add CI/CD workflows (`.github/workflows/*`) unless explicitly requested +- change production behavior "just because" +- do drive-by refactors unrelated to the task +- add a new test framework without explicit user approval (propose minimal scaffolding first) + +#### Testing Policy + +- If the repo already has a test framework/setup, then any change that affects runtime behavior (code/config/API/output) **MUST** include tests. +- **Bug fixes:** add/update a regression test that would fail without the fix. +- **Features/refactors:** add/update targeted tests covering the new/changed behavior. +- **Docs-only or formatting-only changes:** tests are N/A. +- Adding a new test framework is NOT allowed without explicit user approval (propose minimal scaffolding first). +- CI/CD workflow changes (`.github/workflows/*`) remain out of scope unless explicitly requested. + +### 0.4 OpenAPI Documentation Policy (Hard Stop) + +This repository maintains an OpenAPI 3.0 specification (`openapi.yaml`) that documents all HTTP API endpoints exposed by this service. + +**Hard rule:** Any change that adds, modifies, or removes API behavior **MUST** include corresponding updates to `openapi.yaml` in the same change. + +#### What counts as an API change: + +- Adding a new endpoint (path + method) +- Changing request/response schema (body, query params, headers, status codes) +- Modifying endpoint behavior (even if signature is unchanged) +- Deprecating or removing an endpoint +- Changing error response formats + +#### What must be updated in openapi.yaml: + +- `paths:` section (add/modify/remove endpoint) +- `operationId:` (unique identifier for the operation) +- Request `parameters:` and `requestBody:` schemas +- Response `responses:` schemas for all status codes +- `components/schemas:` definitions (if new types introduced) +- `info.version:` (bump patch version for minor changes, minor version for new endpoints) + +#### What does NOT require OpenAPI updates: + +- Internal refactoring (no API signature change) +- Performance improvements (no API signature change) +- Documentation-only changes (e.g., updating README.md) +- Configuration changes that don't affect API behavior + +#### Minimum checklist for API changes: + +- [ ] `openapi.yaml` updated with new/changed endpoint details +- [ ] Request/response schemas match actual implementation +- [ ] All status codes documented (200, 400, 500, etc.) +- [ ] Version bumped in `info.version` +- [ ] Swagger UI validates (start server with `ENABLE_SWAGGER=true`, visit `/swagger`) + +**Blocked without approval:** If Copilot is asked to add/modify an endpoint but not update OpenAPI spec, it must stop and cite this rule. + +--- + +## 1) Ownership & Integration Boundaries (Non-negotiable) + +### 1.1 Leader-owned / Team-owned (Treat as external) + +Copilot must assume the following are **NOT owned by this repo** (do not change assumptions without explicit user instruction): + +- Graph Engine schema design / schema evolution +- metrics source/collection architecture (Prometheus/Grafana/Kiali stack) +- "Graph Engine API" service implementation and contract ownership (leader/team owns it) + +### 1.2 This repo-owned + +This repo owns: + +- predictive analysis logic +- its own HTTP API (endpoints exposed by this service) +- client-side consumption of Graph Engine API + +--- + +## 2) Graph Engine API Policy (Must follow) + +### 2.1 Single source of truth + +Graph Engine API is the **only** data source for graph/topology data: + +1. **Use Graph Engine API** for all graph data needs +2. **No fallback** — if Graph Engine is unavailable, return 503 with clear error +3. Require env var `SERVICE_GRAPH_ENGINE_URL` or `GRAPH_ENGINE_BASE_URL` + +--- + +## 3) Security & Logging Rules (Hard rules) + +- Never print secrets (passwords, tokens, connection strings) to logs. +- Do not hardcode credentials or endpoints. +- Treat env vars + K8s secrets as the only acceptable secret sources unless user says otherwise. + +--- + +## 4) Working Style (How Copilot must behave) + +### 4.1 Plan-first workflow (Always) + +Every task must follow this sequence: + +1. **Inventory (read-only)**: identify relevant files + evidence snippets +2. **Plan**: steps, files to change, risk points, stop conditions +3. **Clarifying questions**: ask only what's needed to be ≥95% confident +4. **Wait** (no edits) until user says `OK IMPLEMENT NOW` +5. **Implement** (only after approval) in small, reversible changes +6. **Summarize**: what changed + manual verification steps + docs touched + +### 4.2 Minimal questions, maximum signal + +Keep questions minimal and practical. Ask questions only when: + +- contract details are missing +- boundaries are unclear +- implementation choices would materially change behavior + +### 4.3 Avoid "progress chatter" + +Copilot must not output filler like "Now I will inspect…". Only output: + +- findings with evidence +- plan +- questions +- next steps + +--- + +## 5) Output Format Requirements (Always follow) + +When responding, Copilot must use this exact structure: + +### A) Evidence Inventory + +- Bullet list of discovered facts with `path:` + snippet blocks (1–5 lines) + +### B) Proposed Plan (No code changes yet) + +- Steps +- Files to create/modify +- Risks +- Stop conditions + +### C) Clarifying Questions (Only what's needed) + +- Group by: Contract / Boundaries / Tone + +### D) Waiting State + +- End with: "Reply with `OK IMPLEMENT NOW` when you want me to create/edit files." + +--- + +## 6) What Copilot is currently expected to build in this repo + +Unless user overrides, the default deliverable is a `.github` pack containing: + +- `.github/copilot-instructions.md` (this file) +- `.github/agents/`: `planner.md`, `implementer.md`, `reviewer.md` +- `.github/instructions/`: operating rules, ownership, Graph API policy, errors/logging, K8s scope +- `.github/prompts/`: reusable workflow prompts +- `.github/skills/`: Agent Skills for specialized workflows (graph-api-client, simulation-runner, k8s-deployment) + +**Also see:** +- `AGENTS.md` (root): Universal agent instructions compatible with any AI agent + +**Blocked until `OK IMPLEMENT NOW`.** + +--- + +## 7) Definition of "Done" + +A task is done only when: + +- The plan has been produced +- Missing context has been asked +- The user approves implementation with `OK IMPLEMENT NOW` +- Files are created/updated exactly as proposed +- **Tests added/updated** when applicable (per Testing Policy in §0.3) +- **OpenAPI spec (`openapi.yaml`) updated** for any API behavior change (add/modify/remove endpoint) per §0.4 +- **Relevant docs updated** when behavior/config/API changes +- **Governance files updated** when the change impacts workflows/standards +- **Verification:** `npm test` run when possible (otherwise provide commands + pass criteria) +- A final summary lists: + - files changed + - tests added/updated + - key rules enforced + - manual checks to perform diff --git a/.github/instructions/00-operating-rules.instructions.md b/.github/instructions/00-operating-rules.instructions.md new file mode 100644 index 0000000..52dc816 --- /dev/null +++ b/.github/instructions/00-operating-rules.instructions.md @@ -0,0 +1,101 @@ +--- +applyTo: "**/*" +description: 'Absolute operating rules that override all other guidance - implementation locks, evidence requirements, and scope limits' +--- + +# Operating Rules + +These rules are absolute and override any other guidance. + +--- + +## Rule 0.1: No-Implementation Lock (Hard Stop) + +Copilot is **NOT allowed** to create, edit, or delete files unless the user explicitly provides this exact approval phrase: + +``` +OK IMPLEMENT NOW +``` + +**Blocked actions without approval:** + +- Creating new files +- Modifying existing files +- Deleting files +- Running commands that modify state + +**Allowed without approval:** + +- Reading files +- Gathering evidence +- Producing plans +- Asking questions + +--- + +## Rule 0.2: No Fake Claims / Evidence Rule (Hard Stop) + +Copilot must not claim it "inspected," "confirmed," or "verified" anything unless it can show evidence. + +**Required format for repo facts:** + +``` +[file path]: +`verbatim snippet (1–5 lines)` +``` + +**If evidence cannot be provided:** + +Copilot must say: **"Unknown (not evidenced yet)"** + +**Forbidden phrases without evidence:** + +- "I searched all files…" +- "I confirmed that…" +- "The codebase does/doesn't…" + +--- + +## Rule 0.3: Scope Limitations (Hard Stop) + +Copilot must **NOT** perform these actions regardless of user request: + +| Blocked Action | Reason | +|----------------|--------| +| Add `.github/workflows/*` | CI/CD is out of scope unless explicitly requested | +| Add new test framework without approval | Must propose minimal scaffolding and get user approval first | +| Change production behavior "just because" | Requires explicit justification | +| Drive-by refactors | Must be part of approved plan | + +**In-scope work:** + +- Agents, guidance, instructions, prompts +- Documentation updates +- Configuration for instruction/prompt packs +- **Tests** (when test framework exists) — see Testing Policy in `.github/copilot-instructions.md` + +> **Testing Policy:** Tests are REQUIRED for behavioral changes (code/config/API/output) when a test framework exists. See full policy in `.github/copilot-instructions.md` under "Testing Policy". + +--- + +## Enforcement + +If Copilot violates any operating rule: + +1. The violation must be reported +2. The action must be rolled back or blocked +3. User must re-approve with explicit acknowledgment + +--- + +## Quick Reference + +| Situation | Copilot Action | +|-----------|----------------| +| User asks for a change | Plan first, wait for `OK IMPLEMENT NOW` | +| User asks for analysis | Provide evidence, no file changes | +| User asks for CI/CD | Refuse, cite Rule 0.3 | +| Behavioral change (code/config/API) | Include tests (per Testing Policy) | +| Docs-only change | Tests are N/A | +| No test framework exists | Propose minimal scaffolding, get approval | +| Copilot can't find evidence | Say "Unknown (not evidenced yet)" | diff --git a/.github/instructions/01-ownership-boundaries.instructions.md b/.github/instructions/01-ownership-boundaries.instructions.md new file mode 100644 index 0000000..4c7659f --- /dev/null +++ b/.github/instructions/01-ownership-boundaries.instructions.md @@ -0,0 +1,92 @@ +--- +applyTo: "**/*" +description: 'Defines what this repository owns vs external team ownership - Graph Engine schema and metrics are external' +--- + +# Ownership Boundaries + +This document defines what this repository owns versus what is owned by external teams. + +--- + +## Leader-Owned / Team-Owned (Treat as External) + +Copilot must assume the following are **NOT owned by this repo**: + +### Graph Engine Schema + +- **Owner:** Leader / Platform Team (via service-graph-engine) +- **This repo's role:** Consumer via HTTP API +- **Copilot must NOT:** + - Propose schema changes + - Assume schema details without evidence from Graph Engine API documentation + - Invent Graph Engine endpoints + +**Important:** This repo consumes graph data via HTTP API; it does not define the schema or data model. + +### Metrics Source/Collection Architecture + +- **Owner:** Leader / Platform Team +- **Components:** Prometheus, Grafana, Kiali stack +- **This repo's role:** Consumer of derived graph data +- **Copilot must NOT:** + - Propose changes to metrics collection + - Assume metrics availability without evidence + +### Graph Engine API Service + +- **Owner:** Leader / Platform Team (service-graph-engine) +- **This repo's role:** HTTP client/consumer +- **Copilot must NOT:** + - Invent Graph Engine API endpoints + - Invent request/response shapes + - Assume contract details without documentation + +--- + +## This Repo Owns + +### Predictive Analysis Simulation Logic + +- All simulation algorithms +- Impact calculation formulas +- Path analysis logic + +### HTTP API (This Service's Endpoints) + +| Endpoint | Owner | +|----------|-------| +| `GET /health` | This repo | +| `POST /simulate/failure` | This repo | +| `POST /simulate/scale` | This repo | + +### Graph Engine HTTP Client Code + +- Client-side consumption of Graph Engine API +- Adapter patterns for graph data access +- Error handling when Graph Engine unavailable (return 503) + +--- + +## Decision Matrix + +| Need | Data Source | Fallback | Copilot Action | +|------|-------------|----------|----------------| +| Graph topology | Graph Engine API | None (return 503) | Use GraphEngineHttpProvider | +| Schema details | Ask leader | Evidence in repo | Never assume | +| Metrics data | Graph Engine API | None (return 503) | Do not access Prometheus directly | +| New endpoint | This repo | N/A | Plan and implement per rules | + +--- + +## Boundary Violations + +If Copilot is asked to cross a boundary, it must: + +1. **Stop** — Do not proceed +2. **Cite** — Reference this document +3. **Ask** — Request explicit user override + +**Example response:** + +> "This request touches Graph Engine schema, which is leader-owned (see `01-ownership-boundaries.md`). Copilot cannot proceed without explicit user approval to cross this boundary." diff --git a/.github/instructions/02-graph-api-first.instructions.md b/.github/instructions/02-graph-api-first.instructions.md new file mode 100644 index 0000000..5352f24 --- /dev/null +++ b/.github/instructions/02-graph-api-first.instructions.md @@ -0,0 +1,181 @@ +--- +applyTo: "**/graphEngineClient.js,**/providers/**/*.js,src/**/*.js" +description: 'Graph Engine HTTP API is the single source of truth for graph data - no alternatives' +--- + +# Graph API First Policy + +Graph Engine HTTP API is the **single source of truth** for all graph and topology data in this repository. + +--- + +## Core Principle + +``` +Graph Engine API → ONLY data source + ↓ +No alternatives → Return 503 if unavailable +``` + +**This is the single-source policy for graph data access.** + +--- + +## When to Use Graph Engine API + +Copilot must use Graph Engine API for: + +- Fetching service topology +- Retrieving edge metrics (rate, latency, error rate) +- Getting node properties (serviceId, name, namespace) +- Any graph traversal operation +- Health checks and data freshness + +**There is no alternative data source. No fallback logic is permitted.** + +--- + +## Error Handling (No Fallback) + +When Graph Engine API is unavailable: + +1. **Return HTTP 503** with clear error message +2. **Include error code**: `GRAPH_ENGINE_UNAVAILABLE` +3. **Do NOT** attempt any fallback logic +4. **Log** the failure (without credentials) + +**Example error response:** +```json +{ + "error": "Graph Engine unavailable", + "code": "GRAPH_ENGINE_UNAVAILABLE", + "message": "Cannot perform simulation without graph data", + "retryable": true +} +``` + +--- + +## Contract Discipline + +### Before Implementing Graph Engine Client Calls + +Copilot must verify: + +- [ ] Endpoint exists in Graph Engine API documentation +- [ ] Request format is documented (URL, params, body) +- [ ] Response format is documented (schema, status codes) +- [ ] Error cases are handled (404, 503, timeout) + +### If Contract is Missing + +Copilot must **STOP** and ask: + +> "The Graph Engine API contract for [operation] is not documented. Please provide the endpoint specification (URL, request/response format) before proceeding." + +### Never Invent + +Copilot must **NEVER**: + +- Make up endpoint paths (e.g., `/api/graph/services`) +- Make up request body shapes +- Make up response structures +- Assume authentication patterns +- Add fallback logic to any alternative data source +- Import direct database drivers + +--- + +## Configuration + +### Required Environment Variable + +```bash +SERVICE_GRAPH_ENGINE_URL=http://service-graph-engine:3000 +# or: GRAPH_ENGINE_BASE_URL=http://service-graph-engine:3000 +``` + +Application must fail to start if this env var is missing. + +### Configuration Pattern + +```javascript +// Example: config.js +graphEngine: { + baseUrl: process.env.SERVICE_GRAPH_ENGINE_URL || process.env.GRAPH_ENGINE_BASE_URL, + timeout: parseInt(process.env.GRAPH_API_TIMEOUT_MS) || 20000 +} + +// Validate on startup +if (!graphEngine.baseUrl) { + console.error('ERROR: SERVICE_GRAPH_ENGINE_URL is required'); + process.exit(1); +} +``` + +--- + +## Implementation Pattern + +When implementing Graph Engine API consumption: + +```javascript +// ✅ CORRECT: Graph Engine only, no fallback +async function getServiceTopology(serviceId) { + try { + return await graphEngineClient.getNeighborhood(serviceId); + } catch (error) { + // Propagate error - no fallback + logger.error('Graph Engine request failed', { + serviceId, + error: error.message + }); + throw new GraphEngineUnavailableError(error); + } +} +``` + +--- + +## Blocked Patterns + +**DO NOT** implement these patterns: + +```javascript +// ❌ WRONG: Fallback logic to alternative data source +if (graphEngineAvailable) { + return await graphEngine.get(); +} else { + return await alternativeSource.query(); +} + +// ❌ WRONG: Dual mode provider +const provider = config.useGraphEngine ? graphEngineProvider : fallbackProvider; + +// ✅ CORRECT: Graph Engine only +const provider = new GraphEngineHttpProvider(); +``` + +--- + +## Verification Checklist + +Before merging Graph Engine client code, verify: + +- [ ] No database driver imports in same file (e.g., direct graph/SQL drivers) +- [ ] No fallback logic present +- [ ] Error handling returns 503 when Graph Engine unavailable +- [ ] Environment variable `SERVICE_GRAPH_ENGINE_URL` is required +- [ ] Tests mock Graph Engine responses only + +--- + +## Quick Reference + +| Situation | Copilot Action | +|-----------|----------------| +| Need graph data | Use Graph Engine API | +| Contract exists | Implement Graph API client | +| Contract missing | Stop, ask user for contract | +| Graph API unavailable | Return 503 with clear error message | +| User asks to add fallback | Refuse, cite this rule | diff --git a/.github/instructions/03-graph-engine-single-source.instructions.md b/.github/instructions/03-graph-engine-single-source.instructions.md new file mode 100644 index 0000000..1691c2d --- /dev/null +++ b/.github/instructions/03-graph-engine-single-source.instructions.md @@ -0,0 +1,164 @@ +--- +applyTo: "**/graphEngineClient.js,**/providers/**/*.js,src/**/*.js" +description: 'Graph Engine is the single source of truth - no alternatives, no fallbacks, no direct database access' +--- + +# Graph Engine Single Source Policy + +Graph Engine HTTP API is the **only** permitted data source for graph/topology data in this repository. + +--- + +## Hard Rules (No Exceptions) + +### Rule 1: Graph Engine Only + +All graph data MUST come from Graph Engine HTTP API: +- Service topology +- Edge metrics (rate, latency, error rate) +- Node properties (serviceId, name, namespace) +- Graph traversal results +- Any derived graph analytics + +### Rule 2: No Alternatives + +Copilot must **NEVER**: +- Add direct database drivers or protocol-specific connections (forbidden) +- Create "fallback" logic to other data sources +- Implement feature flags to bypass Graph Engine +- Add conditional logic like `if (graphEngineUnavailable) { useFallback() }` + +### Rule 3: No Fallback Pattern + +There is **NO FALLBACK**. If Graph Engine is unavailable: +- Return HTTP 503 Service Unavailable +- Include error code `GRAPH_ENGINE_UNAVAILABLE` +- Provide clear error message to client +- Log the failure (without credentials) + +--- + +## What Counts as a Violation + +| Forbidden Pattern | Why It's Blocked | +|-------------------|------------------| +| `if (!graphEngine) { return directDB.query(...) }` | Fallback to direct DB | +| `import graphDB from 'graph-db-driver'` | Direct DB dependency | +| Adding `DIRECT_DB_URI` env var | Alternative data source | +| `graphProvider.getFallback()` | Bypass architecture | +| Feature flag `USE_DB_FALLBACK` | Undermines single source | + +--- + +## Required Patterns + +### 1) Single Provider Interface + +```javascript +// ✅ CORRECT: Single provider, no alternatives +class GraphEngineHttpProvider { + async getTopology() { + try { + return await graphEngineClient.get('/topology'); + } catch (error) { + // No fallback - propagate error + throw new GraphEngineUnavailableError(error); + } + } +} +``` + +### 2) Error Propagation (No Fallback) + +```javascript +// ✅ CORRECT: Fail fast, return 503 +app.post('/simulate/failure', async (req, res) => { + try { + const graph = await graphProvider.getTopology(); + // ... simulation logic + } catch (error) { + if (error instanceof GraphEngineUnavailableError) { + return res.status(503).json({ + error: 'Graph Engine unavailable', + code: 'GRAPH_ENGINE_UNAVAILABLE' + }); + } + throw error; + } +}); +``` + +### 3) Required Environment Variable + +```bash +# REQUIRED - no default, no fallback +SERVICE_GRAPH_ENGINE_URL=http://service-graph-engine:3000 +# or: GRAPH_ENGINE_BASE_URL=... +``` + +Application must fail to start if this env var is missing. + +--- + +## Contract Discipline + +### Before Implementing Graph Engine Client Code + +Copilot must verify: +- [ ] Endpoint exists in Graph Engine API documentation +- [ ] Request format is documented (params, body, headers) +- [ ] Response format is documented (schema, status codes) +- [ ] Error cases are documented (404, 500, timeout) + +### If Contract is Missing + +Copilot must **STOP** and ask: + +> "The Graph Engine API contract for [operation] is not documented. Please provide the endpoint specification (URL, request/response format) before proceeding." + +### Never Invent + +Copilot must **NEVER**: +- Make up endpoint paths (e.g., `/api/services/graph`) +- Assume request body shapes +- Assume response structures +- Invent authentication patterns + +--- + +## Enforcement Checklist + +When reviewing changes that touch graph data access: + +- [ ] No database driver imports (graph-db-driver, pg, mysql2, etc.) +- [ ] No fallback conditional logic +- [ ] No alternative data source env vars +- [ ] Graph Engine client is the only provider +- [ ] Errors propagate to HTTP 503 (no silent fallback) +- [ ] `graphEngineClient` module used exclusively +- [ ] Contract verified before implementation + +--- + +## Violation Response + +If Copilot detects a violation request: + +1. **STOP** — Do not proceed with implementation +2. **CITE** — Reference this document (03-graph-engine-single-source) +3. **ASK** — Request explicit user override + +**Example:** +> "This request adds direct database fallback logic, which violates Graph Engine Single Source Policy (03-graph-engine-single-source.instructions.md). Graph Engine is the only permitted data source. If Graph Engine is unavailable, the service must return 503. Please confirm if you want to override this policy." + +--- + +## Quick Reference + +| Situation | Copilot Action | +|-----------|----------------| +| Graph Engine down | Return 503, no fallback | +| Missing Graph Engine contract | Stop, ask for contract | +| User requests fallback logic | Stop, cite this policy, ask for override | +| New graph data access needed | Use `graphEngineClient` only | +| Alternative data source proposed | Block, cite this policy | diff --git a/.github/instructions/04-errors-logging-secrets.instructions.md b/.github/instructions/04-errors-logging-secrets.instructions.md new file mode 100644 index 0000000..62a43e6 --- /dev/null +++ b/.github/instructions/04-errors-logging-secrets.instructions.md @@ -0,0 +1,175 @@ +--- +applyTo: "**/*.js" +description: 'Security rules for error handling, logging, and secrets - never log credentials, use redactCredentials()' +--- + +# Errors, Logging & Secrets Policy + +This document governs how Copilot must handle errors, logging, and secrets. + +--- + +## Secrets Management + +### Hard Rules + +| Rule | Enforcement | +|------|-------------| +| Never hardcode credentials | ❌ No passwords, tokens, or connection strings in code | +| Never log secrets | ❌ No secrets in console.log, console.error, or any log output | +| Only env vars or K8s secrets | ✅ These are the only acceptable secret sources | + +### Acceptable Secret Sources + +```javascript +// ✅ Environment variables +const apiKey = process.env.GRAPH_ENGINE_API_KEY; + +// ✅ K8s secrets via env injection +// (defined in deployment.yaml, not in code) +env: + - name: GRAPH_ENGINE_API_KEY + valueFrom: + secretKeyRef: + name: graph-engine-credentials + key: API_KEY +``` + +### Forbidden Patterns + +```javascript +// ❌ NEVER do this +const apiKey = 'sk-1234567890abcdef'; +const url = 'https://api.example.com?key=secret-key-here'; +console.log('Connecting with API key:', apiKey); +``` + +--- + +## Credential Redaction + +The repo has a `redactCredentials()` function. Copilot must use this pattern. + +### Credential Redaction Pattern + +When logging errors that may contain sensitive data: + +```javascript +// Generic pattern for redacting credentials +function redactSensitiveData(message) { + if (!message) return message; + return message + .replace(/password=([^&\s]+)/gi, 'password=[REDACTED]') + .replace(/apikey=([^&\s]+)/gi, 'apikey=[REDACTED]') + .replace(/token=([^&\s]+)/gi, 'token=[REDACTED]'); +} +``` + +### When to Apply + +| Situation | Action | +|-----------|--------| +| Logging error messages | Apply redaction | +| Returning errors in HTTP responses | Apply redaction | +| Logging connection strings | Apply redaction | +| Logging configuration | Never log password fields | + +--- + +## Error Handling + +### HTTP Status Mapping + +The repo uses message-based status mapping: + +```javascript +// From index.js — FOLLOW THIS PATTERN +if (error.message.includes('not found')) { + res.status(404).json({ error: error.message }); +} else if (error.message.includes('timeout')) { + res.status(504).json({ error: error.message }); +} else if (error.message.includes('must') || error.message.includes('invalid')) { + res.status(400).json({ error: error.message }); +} else { + console.error('Simulation error:', error); + res.status(500).json({ error: 'Internal server error' }); +} +``` + +### Error Response Format + +```javascript +// Standard error response +{ + "error": "Human-readable error message" +} + +// Never include in error responses: +// - Stack traces (in production) +// - Credentials +// - Internal system details +``` + +--- + +## Logging Rules + +### What to Log + +| Category | Log Level | Example | +|----------|-----------|---------| +| Server startup | info | Port, config summary | +| Health checks | debug | Connection status | +| Simulation requests | info | Service ID, parameters (no secrets) | +| Errors | error | Redacted error messages | + +### What NOT to Log + +| Category | Reason | +|----------|--------| +| Passwords / API keys | Security violation | +| Full connection strings | May contain credentials | +| Raw error messages from external services | May contain sensitive data | +| Request bodies with secrets | Security violation | + +### Log Format + +```javascript +// ✅ Good: No secrets, structured info +console.log(`Simulation request: serviceId=${serviceId}, maxDepth=${maxDepth}`); + +// ❌ Bad: Contains potential secrets +console.log('Full config object:', config); +``` + +--- + +## Configuration Logging at Startup + +Safe to log: + +```javascript +console.log(`Port: ${config.server.port}`); +console.log(`Max traversal depth: ${config.simulation.maxTraversalDepth}`); +console.log(`Default latency metric: ${config.simulation.defaultLatencyMetric}`); +``` + +Never log: + +```javascript +// ❌ NEVER +console.log(`API key: ${config.apiKey}`); +console.log(`Full config with secrets:`, config); +``` + +--- + +## Quick Reference + +| Situation | Copilot Action | +|-----------|----------------| +| Adding error handling | Use message-based status mapping | +| Logging errors | Apply redactCredentials() | +| Adding new config | Use env vars, never hardcode | +| Modifying HTTP responses | Never include credentials | +| Startup logging | Log safe config only | diff --git a/.github/instructions/05-k8s-minikube-scope.instructions.md b/.github/instructions/05-k8s-minikube-scope.instructions.md new file mode 100644 index 0000000..5c57378 --- /dev/null +++ b/.github/instructions/05-k8s-minikube-scope.instructions.md @@ -0,0 +1,179 @@ +--- +applyTo: "k8s/**/*,**/Dockerfile,**/*.yaml" +description: 'Kubernetes deployment context - Kustomize structure, Minikube local development, and manifest guidelines' +--- + +# Kubernetes & Minikube Scope + +This document defines the Kubernetes deployment context for this service. + +--- + +## Deployment Structure + +The repo uses Kustomize for K8s manifests: + +``` +k8s/ +└── base/ + ├── kustomization.yaml + ├── deployment.yaml + └── service.yaml +``` + +--- + +## Supported Profiles + +### Profile A: Remote Graph Engine + +- **Graph Engine:** Cloud-hosted or remote cluster +- **Connection:** HTTP URL via environment variable +- **Use case:** Production-like, staging environments + +```yaml +# Environment config +SERVICE_GRAPH_ENGINE_URL: http://service-graph-engine.production.svc.cluster.local:8080 +``` + +### Profile B: Local Graph Engine (Minikube) + +- **Graph Engine:** Local instance in Minikube +- **Connection:** Local cluster DNS +- **Use case:** Local development, testing + +```yaml +# Environment config for local +SERVICE_GRAPH_ENGINE_URL: http://service-graph-engine:8080 +``` + +--- + +## Configuration Management + +### Pattern (from deployment.yaml) + +```yaml +env: + - name: SERVICE_GRAPH_ENGINE_URL + value: "http://service-graph-engine:8080" + - name: GRAPH_ENGINE_TIMEOUT_MS + value: "5000" +``` + +### Using ConfigMap (alternative) + +```bash +# For production +kubectl create configmap predictive-engine-config \ + --from-literal=SERVICE_GRAPH_ENGINE_URL='http://service-graph-engine.production.svc.cluster.local:8080' + +# For local development +kubectl create configmap predictive-engine-config \ + --from-literal=SERVICE_GRAPH_ENGINE_URL='http://service-graph-engine:8080' +``` + +--- + +## Resource Limits + +From `deployment.yaml`: + +```yaml +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 300m + memory: 256Mi +``` + +Copilot must preserve these limits unless explicitly asked to change them. + +--- + +## Health Probes + +From `deployment.yaml`: + +```yaml +readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + +livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 30 +``` + +Copilot must preserve health probe configuration. + +--- + +## Security Context + +From `deployment.yaml`: + +```yaml +securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 +``` + +Copilot must preserve security context settings. + +--- + +## Scope Limitations + +### Copilot CAN + +- Modify deployment.yaml for configuration changes +- Add new environment variables (non-secret) +- Adjust resource limits (if requested) +- Update labels/annotations + +### Copilot CANNOT + +- Add Helm charts (use existing Kustomize) +- Add CI/CD workflows +- Create new overlay directories without approval +- Modify secrets management patterns + +--- + +## Local Development (Non-K8s) + +For local development without K8s: + +```bash +# 1. Copy .env.example to .env +cp .env.example .env + +# 2. Configure Graph Engine URL +# SERVICE_GRAPH_ENGINE_URL=http://localhost:8080 (local) +# OR +# SERVICE_GRAPH_ENGINE_URL=http://service-graph-engine.production:8080 (remote) + +# 3. Start server +npm start +``` + +--- + +## Quick Reference + +| Environment | Graph Engine URL Pattern | Config Source | +|-------------|--------------------------|---------------| +| Local dev | http://localhost:8080 | .env file | +| Minikube | http://service-graph-engine:8080 | ConfigMap or env | +| Production | http://service-graph-engine.prod:8080 | ConfigMap or env | diff --git a/.github/instructions/06-external-service-resilience.instructions.md b/.github/instructions/06-external-service-resilience.instructions.md new file mode 100644 index 0000000..4986527 --- /dev/null +++ b/.github/instructions/06-external-service-resilience.instructions.md @@ -0,0 +1,239 @@ +--- +applyTo: "**/graphEngineClient.js,src/**/*.js,index.js" +description: 'Timeout, error handling, and health check patterns for external service dependencies' +--- + +# External Service Resilience Policy + +This document defines required patterns for consuming external HTTP services (Graph Engine, etc.). + +--- + +## Required Patterns + +### 1) Timeouts (Always Set) + +All HTTP requests to external services MUST have explicit timeouts: + +```javascript +// ✅ CORRECT: Explicit timeout +const response = await axios.get(url, { + timeout: config.GRAPH_API_TIMEOUT_MS || 20000 +}); + +// ❌ FORBIDDEN: No timeout (hangs indefinitely) +const response = await axios.get(url); +``` + +**Default timeout:** 20000ms (20 seconds) +**Configuration:** Via `GRAPH_API_TIMEOUT_MS` env var + +--- + +### 2) Error Handling (Structured) + +All external service errors MUST be caught and classified: + +```javascript +// ✅ CORRECT: Structured error handling +try { + const data = await graphEngineClient.get('/topology'); + return data; +} catch (error) { + if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') { + // Service unavailable + throw new ServiceUnavailableError('Graph Engine', error); + } else if (error.response?.status === 404) { + // Not found + throw new NotFoundError('Topology data not found'); + } else if (error.response?.status >= 500) { + // Upstream server error + throw new UpstreamError('Graph Engine', error); + } else { + // Unknown error + throw error; + } +} +``` + +**Error Classification:** +- `ECONNREFUSED` / `ETIMEDOUT` → Service unavailable (503) +- HTTP 404 → Not found (404) +- HTTP 500-599 → Upstream error (502) +- HTTP 400-499 → Client error (propagate status) + +--- + +### 3) Error Response Format + +When external service failures cause endpoint failures, return structured errors: + +```javascript +// ✅ CORRECT: Structured error response +{ + "error": "Graph Engine unavailable", + "code": "GRAPH_ENGINE_UNAVAILABLE", + "message": "Unable to fetch topology data", + "retryable": false +} +``` + +**Required fields:** +- `error` — Human-readable message +- `code` — Machine-readable error code +- `message` — Detailed context +- `retryable` — Boolean (true if client can retry) + +**Standard error codes:** +- `GRAPH_ENGINE_UNAVAILABLE` — Service down or unreachable +- `GRAPH_ENGINE_TIMEOUT` — Request exceeded timeout +- `GRAPH_ENGINE_ERROR` — Upstream returned 500 +- `INVALID_REQUEST` — Client sent bad request +- `NOT_FOUND` — Resource not found + +--- + +### 4) Health Endpoint (Degraded State) + +The `/health` endpoint MUST report degraded state when external dependencies fail: + +```javascript +// ✅ CORRECT: Health check with dependency status +app.get('/health', async (req, res) => { + const health = { status: 'ok', dependencies: {} }; + + try { + await graphEngineClient.get('/health', { timeout: 2000 }); + health.dependencies.graphEngine = { status: 'ok' }; + } catch (error) { + health.dependencies.graphEngine = { + status: 'unavailable', + error: error.message + }; + health.status = 'degraded'; + } + + const statusCode = health.status === 'ok' ? 200 : 503; + res.status(statusCode).json(health); +}); +``` + +**Health states:** +- `ok` — All dependencies healthy (200) +- `degraded` — Some dependencies down (503) +- `down` — Critical failure (503) + +**Health check timeout:** 2000ms (faster than API timeout) + +--- + +### 5) Logging (No Credentials) + +Log external service failures without exposing credentials: + +```javascript +// ✅ CORRECT: Redacted logging +logger.error('Graph Engine request failed', { + endpoint: '/topology', + url: redactUrl(fullUrl), // Remove credentials + error: error.message, + code: error.code, + duration: elapsedMs +}); + +// ❌ FORBIDDEN: Credential exposure +logger.error('Request failed', { + url: 'http://user:password@graph-engine:3000/topology' // BAD! +}); +``` + +**Redaction rules:** +- Strip username/password from URLs +- Mask API tokens/keys +- Never log full Authorization headers +- Use `redactCredentials()` helper (from 04-errors-logging-secrets) + +--- + +## Configuration + +### Required Environment Variables + +```bash +# Graph Engine base URL (required) +SERVICE_GRAPH_ENGINE_URL=http://service-graph-engine:3000 + +# Timeout for Graph Engine requests (optional, default: 20000) +GRAPH_API_TIMEOUT_MS=20000 + +# Health check timeout (optional, default: 2000) +HEALTH_CHECK_TIMEOUT_MS=2000 +``` + +### Validation on Startup + +Application MUST validate required env vars and fail fast: + +```javascript +// ✅ CORRECT: Startup validation +if (!config.SERVICE_GRAPH_ENGINE_URL) { + console.error('ERROR: SERVICE_GRAPH_ENGINE_URL is required'); + process.exit(1); +} +``` + +--- + +## Anti-Patterns (Forbidden) + +| Anti-Pattern | Why Forbidden | Correct Approach | +|--------------|---------------|------------------| +| No timeout on HTTP requests | Hangs indefinitely | Set explicit timeout | +| Swallowing errors silently | Hides failures | Log and propagate | +| Returning 200 when dependency down | Misleads clients | Return 503 | +| Logging full URLs with credentials | Security risk | Use redactUrl() | +| Hardcoded service URLs | Not configurable | Use env vars | +| No health endpoint | Can't monitor | Add /health with deps | + +--- + +## Testing Requirements + +When adding/modifying external service integrations: + +- [ ] Test timeout behavior (mock slow response) +- [ ] Test connection refused (service down) +- [ ] Test HTTP 500 errors (upstream failure) +- [ ] Test HTTP 404 errors (not found) +- [ ] Verify error response format +- [ ] Verify health endpoint reflects dependency status +- [ ] Verify no credentials in logs + +--- + +## Enforcement Checklist + +When reviewing external service client code: + +- [ ] All HTTP requests have explicit timeouts +- [ ] Errors are caught and classified +- [ ] Error responses follow standard format +- [ ] Health endpoint checks dependencies +- [ ] Logs use credential redaction +- [ ] Required env vars validated on startup +- [ ] No hardcoded service URLs +- [ ] Tests cover timeout/error scenarios + +--- + +## Quick Reference + +| Scenario | Action | HTTP Status | +|----------|--------|-------------| +| Service unreachable | Return structured error | 503 | +| Request timeout | Return timeout error | 503 | +| Upstream 500 error | Return upstream error | 502 | +| Upstream 404 error | Return not found | 404 | +| Client bad request | Return validation error | 400 | +| All dependencies healthy | Health = ok | 200 | +| Any dependency down | Health = degraded | 503 | diff --git a/.github/prompts/01-plan-change.prompt.md b/.github/prompts/01-plan-change.prompt.md new file mode 100644 index 0000000..0ec8100 --- /dev/null +++ b/.github/prompts/01-plan-change.prompt.md @@ -0,0 +1,98 @@ +--- +description: Plan a change before implementing — gather evidence, propose steps, identify risks. +agent: Planner +--- + +# Prompt: Plan a Change + +Use this prompt when you want Copilot to analyze and plan a change before implementing. + +--- + +## Prompt Template + +``` +I need to [describe the change you want]. + +Before implementing, please: +1. Gather evidence from the codebase +2. Identify affected files +3. Propose a step-by-step plan +4. List any risks or stop conditions +5. Ask clarifying questions if needed + +Do NOT implement until I say "OK IMPLEMENT NOW". +``` + +--- + +## Example Usage + +### Adding a new endpoint + +``` +I need to add a new endpoint POST /simulate/latency that analyzes latency impact. + +Before implementing, please: +1. Gather evidence from the codebase +2. Identify affected files +3. Propose a step-by-step plan +4. List any risks or stop conditions +5. Ask clarifying questions if needed + +Do NOT implement until I say "OK IMPLEMENT NOW". +``` + +### Modifying existing behavior + +``` +I need to change the default MAX_TRAVERSAL_DEPTH from 2 to 3. + +Before implementing, please: +1. Gather evidence from the codebase +2. Identify affected files +3. Propose a step-by-step plan +4. List any risks or stop conditions +5. Ask clarifying questions if needed + +Do NOT implement until I say "OK IMPLEMENT NOW". +``` + +--- + +## Expected Response Format + +Copilot should respond with: + +``` +## A) Evidence Inventory +- [file]: `snippet` +- [file]: `snippet` + +## B) Proposed Plan +1. Step one +2. Step two +- Files: list of files +- Test plan: what tests to add/update (or N/A for docs-only) +- Risks: identified risks +- Stop conditions: when to halt + +## C) Clarifying Questions +- Question 1? +- Question 2? + +## D) Waiting State +Reply with `OK IMPLEMENT NOW` when ready. +``` + +> **Testing:** For behavioral changes, include tests per Testing Policy in `.github/copilot-instructions.md` + +--- + +## Approval + +When satisfied with the plan, reply: + +``` +OK IMPLEMENT NOW +``` diff --git a/.github/prompts/02-implement-approved-plan.prompt.md b/.github/prompts/02-implement-approved-plan.prompt.md new file mode 100644 index 0000000..1aa816c --- /dev/null +++ b/.github/prompts/02-implement-approved-plan.prompt.md @@ -0,0 +1,104 @@ +--- +description: Trigger implementation of an approved plan after user says OK IMPLEMENT NOW. +agent: Implementer +--- + +# Prompt: Implement Approved Plan + +Use this prompt after a plan has been approved to trigger implementation. + +--- + +## Prompt Template + +``` +OK IMPLEMENT NOW + +Please implement the approved plan: +[reference the plan or paste key points] + +After implementation, provide: +1. List of files created/modified +2. Key rules that were enforced +3. Manual verification steps +``` + +--- + +## Example Usage + +### After plan approval + +``` +OK IMPLEMENT NOW + +Please implement the approved plan for adding POST /simulate/latency. + +After implementation, provide: +1. List of files created/modified +2. Key rules that were enforced +3. Manual verification steps +``` + +### With specific instructions + +``` +OK IMPLEMENT NOW + +Implement the plan with these adjustments: +- Use "latencyAnalysis" as the function name instead of "analyzeLatency" +- Add detailed JSDoc comments + +After implementation, provide: +1. List of files created/modified +2. Key rules that were enforced +3. Manual verification steps +``` + +--- + +## Expected Response Format + +Copilot should respond with: + +``` +## Implementation Summary + +### Files Created +- `path/to/new/file.js` + +### Files Modified +- `path/to/existing.js` (lines X-Y) + +### Tests Added/Updated +- `test/feature.test.js` (new) +- `test/existing.test.js` (updated) +- Or: N/A (docs-only change) + +### Key Rules Enforced +- Graph Engine single source policy +- Credential redaction used +- Timeout pattern maintained +- Testing Policy followed + +### Verification +- `npm test` result: X passed, 0 failed +- (Or: commands to run + expected pass criteria) + +### Manual Verification Steps +1. Run `npm start` +2. Test endpoint: `curl -X POST localhost:5000/simulate/latency ...` +3. Verify Graph Engine integration working +``` + +> **Testing:** Per Testing Policy in `.github/copilot-instructions.md` + +--- + +## Post-Implementation + +After implementation, you may want to: + +1. **Review changes:** Ask Copilot to review +2. **Test manually:** Follow verification steps +3. **Iterate:** Request adjustments if needed diff --git a/.github/prompts/03-graph-api-consumer.prompt.md b/.github/prompts/03-graph-api-consumer.prompt.md new file mode 100644 index 0000000..1f68e8c --- /dev/null +++ b/.github/prompts/03-graph-api-consumer.prompt.md @@ -0,0 +1,118 @@ +# Prompt: Implement Graph API Consumer + +Use this prompt when implementing client code to consume the leader's Graph API. + +--- + +## Prompt Template + +``` +I need to consume the Graph API for [describe the data/operation needed]. + +Here is the contract: +- Endpoint: [method] [path] +- Request: [describe request body/params] +- Response: [describe response format] + +Please: +1. Plan the implementation following Graph API First policy +2. Return 503 if Graph Engine unavailable (no fallback) +3. Use SERVICE_GRAPH_ENGINE_URL env var +4. Handle errors appropriately + +Do NOT implement until I say "OK IMPLEMENT NOW". +``` + +--- + +## Example Usage + +### With known contract + +``` +I need to consume the Graph API for fetching service topology. + +Here is the contract: +- Endpoint: GET /api/v1/services/{serviceId}/topology +- Request: path param serviceId (format: "namespace:name") +- Response: { nodes: [...], edges: [...] } + +Please: +1. Plan the implementation following Graph API First policy +2. Return 503 if Graph Engine unavailable (no fallback) +3. Use SERVICE_GRAPH_ENGINE_URL env var +4. Handle errors appropriately + +Do NOT implement until I say "OK IMPLEMENT NOW". +``` + +### Without contract (discovery) + +``` +I need to consume the Graph API for fetching service topology, but I don't have the contract documented. + +Please: +1. Check if a contract exists in this repo +2. If not, tell me what information you need +3. Do not invent endpoints or response shapes + +Do NOT implement until I provide the contract and say "OK IMPLEMENT NOW". +``` + +--- + +## Expected Response Format + +### If contract is provided: + +``` +## A) Evidence Inventory +- Config pattern: [file]: `snippet` +- Existing HTTP client usage: [file]: `snippet` + +## B) Proposed Plan +1. Add Graph API client module +2. Implement topology fetcher with fallback +3. Update config.js for GRAPH_API_BASE_URL +- Files: src/graphApiClient.js, src/config.js +- Risks: API unavailability, response format changes + +## C) Clarifying Questions +- Authentication: Does the API require auth headers? +- Timeout: Should I use 20000ms (current Graph Engine timeout)? + +## D) Waiting State +Reply with `OK IMPLEMENT NOW` when ready. +``` + +### If contract is missing: + +``` +## Contract Required + +The Graph API contract is not documented in this repo. Per Graph API First policy, I cannot invent endpoints. + +Please provide: +- Endpoint URL pattern +- HTTP method +- Request format (body/params) +- Response format +- Authentication requirements (if any) + +Once you provide the contract, I will plan the implementation. +``` + +--- + +## Configuration Pattern + +When implementing, Copilot should use: + +```javascript +// config.js +graphApi: { + baseUrl: process.env.GRAPH_API_BASE_URL, + enabled: !!process.env.GRAPH_API_BASE_URL, + timeoutMs: parseInt(process.env.GRAPH_API_TIMEOUT_MS) || 20000 +} +``` diff --git a/.github/prompts/04-graph-engine-integration.prompt.md b/.github/prompts/04-graph-engine-integration.prompt.md new file mode 100644 index 0000000..ae368cd --- /dev/null +++ b/.github/prompts/04-graph-engine-integration.prompt.md @@ -0,0 +1,296 @@ +--- +name: "Graph Engine Integration Workflow" +description: "Step-by-step workflow for adding or modifying Graph Engine API dependencies" +--- + +# Graph Engine Integration Workflow + +Use this prompt when adding new Graph Engine API endpoints or modifying existing integrations. + +--- + +## Trigger Conditions + +Use this workflow when: +- Adding a new endpoint that needs graph/topology data +- Modifying existing graph data consumption +- Changing Graph Engine API client code +- Updating graph data provider logic + +--- + +## Pre-Implementation Checklist + +Before writing code, verify: + +### 1) Contract Validation +- [ ] Graph Engine API endpoint is documented +- [ ] Request format is known (URL, params, body, headers) +- [ ] Response format is known (schema, status codes) +- [ ] Error cases are documented (404, 500, timeout) + +**If contract is missing:** STOP and ask user for endpoint specification. + +### 2) Policy Compliance +- [ ] Review `.github/instructions/03-graph-engine-single-source.instructions.md` +- [ ] Review `.github/instructions/06-external-service-resilience.instructions.md` +- [ ] Confirm no fallback logic will be added +- [ ] Confirm timeout/error handling will be implemented + +### 3) Existing Code Audit +- [ ] Search for similar Graph Engine API usage: `git grep -n "graphEngineClient"` +- [ ] Identify existing patterns to follow +- [ ] Check for existing error handling helpers + +--- + +## Implementation Steps + +### Step 1: Define Graph Engine Client Method + +Add method to `src/graphEngineClient.js`: + +```javascript +async getNewResource(params) { + const url = `/api/new-resource`; // Verify endpoint exists + + try { + const response = await this.client.get(url, { + params, + timeout: this.timeout + }); + return response.data; + } catch (error) { + logger.error('Graph Engine request failed', { + endpoint: url, + params, + error: error.message, + code: error.code + }); + throw this.handleError(error); + } +} +``` + +**Required:** +- Explicit timeout +- Error logging (no credentials) +- Error classification via `handleError()` + +### Step 2: Update Provider Layer + +Update `src/providers/GraphEngineHttpProvider.js`: + +```javascript +async getNewData(params) { + try { + return await this.client.getNewResource(params); + } catch (error) { + // No fallback - propagate error + throw error; + } +} +``` + +**Critical:** No fallback logic, no alternative data sources. + +### Step 3: Add Endpoint Handler + +Add route in `index.js`: + +```javascript +app.get('/api/new-endpoint', async (req, res) => { + try { + const data = await graphProvider.getNewData(req.query); + res.json(data); + } catch (error) { + if (error.code === 'GRAPH_ENGINE_UNAVAILABLE') { + return res.status(503).json({ + error: 'Graph Engine unavailable', + code: 'GRAPH_ENGINE_UNAVAILABLE', + retryable: true + }); + } + // Handle other errors + res.status(500).json({ error: error.message }); + } +}); +``` + +**Required:** +- Return 503 when Graph Engine unavailable +- Structured error responses +- No silent failures + +### Step 4: Update OpenAPI Spec + +Update `openapi.yaml`: + +```yaml +paths: + /api/new-endpoint: + get: + summary: New endpoint description + parameters: [...] + responses: + 200: + description: Success + content: + application/json: + schema: {...} + 503: + description: Graph Engine unavailable + content: + application/json: + schema: + type: object + properties: + error: + type: string + code: + type: string + retryable: + type: boolean +``` + +**Bump version** in `info.version` (patch or minor). + +### Step 5: Add Tests + +Create test in `test/`: + +```javascript +test('new endpoint returns data when Graph Engine available', async () => { + // Mock Graph Engine response + nock('http://graph-engine:3000') + .get('/api/new-resource') + .reply(200, { data: [...] }); + + const response = await request(app).get('/api/new-endpoint'); + assert.strictEqual(response.status, 200); +}); + +test('new endpoint returns 503 when Graph Engine unavailable', async () => { + // Mock Graph Engine failure + nock('http://graph-engine:3000') + .get('/api/new-resource') + .replyWithError({ code: 'ECONNREFUSED' }); + + const response = await request(app).get('/api/new-endpoint'); + assert.strictEqual(response.status, 503); + assert.strictEqual(response.body.code, 'GRAPH_ENGINE_UNAVAILABLE'); +}); +``` + +**Required test scenarios:** +- Happy path (Graph Engine returns 200) +- Service unavailable (connection refused) +- Timeout (slow response) +- Upstream error (Graph Engine returns 500) + +--- + +## Post-Implementation Verification + +### 1) Code Quality Checks + +Run these commands: + +```bash +# No direct database drivers in runtime code +git grep -n -E "(require|import).*driver" -- src/ test/ | grep -v graphEngine + +# No fallback logic +git grep -n -i "fallback.*database" -- src/ + +# Verify timeout usage +git grep -n "timeout:" src/graphEngineClient.js + +# Verify error handling +git grep -n "catch (error)" src/ +``` + +### 2) Test Execution + +```bash +npm test +``` + +All tests must pass. + +### 3) OpenAPI Validation + +If Swagger UI is enabled: + +```bash +ENABLE_SWAGGER=true npm start +# Visit http://localhost:3000/swagger +# Verify new endpoint appears and is valid +``` + +### 4) Documentation Updates + +- [ ] Update README.md if new endpoint added +- [ ] Update DEPLOYMENT.md if new env vars required +- [ ] Update docs/COPILOT-USAGE-GUIDE.md if workflow changed + +--- + +## Regression Prevention Checklist + +- [ ] No database driver imports added +- [ ] No fallback conditional logic +- [ ] No alternative data source env vars +- [ ] `graphEngineClient` used exclusively +- [ ] Errors propagate to HTTP 503 (not swallowed) +- [ ] Tests cover both success and failure paths +- [ ] OpenAPI spec matches implementation +- [ ] Docs updated + +--- + +## Final Summary Template + +After implementation, provide this summary: + +``` +## Changes Made + +### Files Modified: +- `src/graphEngineClient.js` — Added getNewResource() method +- `src/providers/GraphEngineHttpProvider.js` — Added getNewData() method +- `index.js` — Added /api/new-endpoint route +- `openapi.yaml` — Added endpoint specification, bumped version +- `test/new-endpoint.test.js` — Added tests (success + failure) + +### Key Patterns Followed: +✅ Graph Engine single source (03-graph-engine-single-source) +✅ Timeout/error handling (06-external-service-resilience) +✅ OpenAPI updated (§0.4) +✅ Tests added (§0.3) + +### Verification Results: +✅ No direct DB access: git grep clean +✅ No fallback logic: git grep clean +✅ Tests passing: npm test +✅ OpenAPI valid: Swagger UI validates + +### Manual Checks Required: +- [ ] Start service: npm start +- [ ] Test endpoint: curl http://localhost:3000/api/new-endpoint +- [ ] Test Graph Engine down scenario: stop Graph Engine, verify 503 +``` + +--- + +## When to Deviate + +This workflow can be skipped for: +- Documentation-only changes +- Non-graph-related features +- Internal refactoring (no API changes) + +Always follow this workflow for: +- New Graph Engine API consumption +- Modifying existing graph data access +- Changes to error handling patterns diff --git a/.github/prompts/05-add-or-change-endpoint.prompt.md b/.github/prompts/05-add-or-change-endpoint.prompt.md new file mode 100644 index 0000000..1a7394d --- /dev/null +++ b/.github/prompts/05-add-or-change-endpoint.prompt.md @@ -0,0 +1,124 @@ +# Prompt: Add or Change HTTP Endpoint + +Use this prompt when adding a new API endpoint or modifying an existing one. + +--- + +## Prompt Template + +``` +I need to [add/modify] an endpoint: +- Method: [GET/POST/PUT/DELETE] +- Path: [/path/to/endpoint] +- Purpose: [what it does] +- Request: [body/params format] +- Response: [expected response format] + +Please: +1. Check existing endpoint patterns in index.js +2. Follow the validation patterns from validator.js +3. Use consistent error handling (status codes, messages) +4. Preserve timeout patterns for any async operations +5. Document the endpoint in README.md +6. Update `openapi.yaml` to reflect the API change (see `.github/copilot-instructions.md` §0.4) + +Do NOT implement until I say "OK IMPLEMENT NOW". +``` + +--- + +## Example Usage + +### Adding a new endpoint + +``` +I need to add an endpoint: +- Method: POST +- Path: /simulate/cascade +- Purpose: Simulate cascading failure across multiple services +- Request: { serviceIds: ["default:svc1", "default:svc2"], maxDepth: 2 } +- Response: { affected: [...], totalImpact: {...} } + +Please: +1. Check existing endpoint patterns in index.js +2. Follow the validation patterns from validator.js +3. Use consistent error handling (status codes, messages) +4. Preserve timeout patterns for any async operations +5. Document the endpoint in README.md +6. Update `openapi.yaml` to reflect the API change (see `.github/copilot-instructions.md` §0.4) + +Do NOT implement until I say "OK IMPLEMENT NOW". +``` + +### Modifying an existing endpoint + +``` +I need to modify the POST /simulate/failure endpoint: +- Add optional parameter: includeMetrics (boolean) +- When true, include edge metrics in response +- Default: false (backward compatible) + +Please: +1. Show current implementation +2. Propose changes with backward compatibility +3. Update validation as needed +4. Update README documentation + +Do NOT implement until I say "OK IMPLEMENT NOW". +``` + +--- + +## Expected Response Format + +``` +## A) Evidence Inventory +- Existing endpoint pattern: [index.js]: `snippet` +- Validation pattern: [validator.js]: `snippet` +- Error handling pattern: [index.js]: `snippet` + +## B) Proposed Plan + +### New/Modified Files +- `index.js`: Add endpoint handler +- `src/cascadeSimulation.js`: Implement simulation logic +- `src/validator.js`: Add validation functions +- `README.md`: Document endpoint + +### Endpoint Implementation Outline +```javascript +app.post('/simulate/cascade', async (req, res) => { + try { + // Validate + // Execute with timeout + // Return response + } catch (error) { + // Error handling per existing pattern + } +}); +``` + +### Error Status Mapping +- 400: Invalid request (missing params, invalid format) +- 404: Service not found +- 504: Timeout exceeded +- 500: Internal error + +## C) Clarifying Questions +- Should serviceIds accept both formats (serviceId and name+namespace)? +- Maximum number of serviceIds allowed in one request? + +## D) Waiting State +Reply with `OK IMPLEMENT NOW` when ready. +``` + +--- + +## Endpoint Checklist + +- [ ] Follows existing route patterns +- [ ] Input validation using validator.js patterns +- [ ] Timeout protection (Promise.race) +- [ ] Consistent error status codes +- [ ] Credential redaction in error logs +- [ ] README documentation updated diff --git a/.github/prompts/06-docs-update.prompt.md b/.github/prompts/06-docs-update.prompt.md new file mode 100644 index 0000000..5230f63 --- /dev/null +++ b/.github/prompts/06-docs-update.prompt.md @@ -0,0 +1,114 @@ +# Prompt: Update Documentation + +Use this prompt when you need to update README or other documentation. + +--- + +## Prompt Template + +``` +I need to update documentation for: +- [What changed or needs documenting] + +Please: +1. Find the relevant documentation files +2. Propose additions/changes +3. Keep the existing style and formatting +4. Do not remove existing content unless outdated + +Do NOT implement until I say "OK IMPLEMENT NOW". +``` + +--- + +## Example Usage + +### Documenting a new endpoint + +``` +I need to update documentation for: +- New endpoint POST /simulate/cascade +- Request format: { serviceIds: [...], maxDepth: 2 } +- Response format: { affected: [...], totalImpact: {...} } + +Please: +1. Find the relevant documentation files +2. Propose additions/changes +3. Keep the existing style and formatting +4. Do not remove existing content unless outdated + +Do NOT implement until I say "OK IMPLEMENT NOW". +``` + +### Updating configuration docs + +``` +I need to update documentation for: +- New environment variable GRAPH_API_BASE_URL +- Purpose: Base URL for Graph API when enabled +- Default: none (disabled when not set) + +Please: +1. Find where config is documented (README.md, DEPLOYMENT.md) +2. Add to the configuration table +3. Keep consistent formatting + +Do NOT implement until I say "OK IMPLEMENT NOW". +``` + +--- + +## Expected Response Format + +``` +## A) Evidence Inventory +- Main docs: [README.md]: current API section +- Deployment docs: [DEPLOYMENT.md]: current config section + +## B) Proposed Changes + +### README.md +Add to API Reference section: +```markdown +### Cascade Failure Simulation + +**Endpoint:** `POST /simulate/cascade` + +**Request:** +\`\`\`json +{ + "serviceIds": ["default:svc1", "default:svc2"], + "maxDepth": 2 +} +\`\`\` +... +``` + +### Configuration Table Addition +| Variable | Default | Description | +|----------|---------|-------------| +| `GRAPH_API_BASE_URL` | *(none)* | Base URL for Graph API | + +## C) Waiting State +Reply with `OK IMPLEMENT NOW` when ready. +``` + +--- + +## Documentation Files in This Repo + +| File | Purpose | +|------|---------| +| `README.md` | Main documentation (API, config, architecture) | +| `DEPLOYMENT.md` | Deployment instructions | +| `.github/instructions/*.md` | Copilot instructions | +| `.github/prompts/*.md` | Reusable prompts | + +--- + +## Style Guidelines + +- Use existing heading hierarchy +- Match code block formatting (json, bash, javascript) +- Keep tables aligned +- Use consistent terminology (serviceId, not service_id) diff --git a/.github/prompts/07-pr-summary.prompt.md b/.github/prompts/07-pr-summary.prompt.md new file mode 100644 index 0000000..bd94824 --- /dev/null +++ b/.github/prompts/07-pr-summary.prompt.md @@ -0,0 +1,114 @@ +# Prompt: Generate PR Summary + +Use this prompt to generate a pull request summary after completing changes. + +--- + +## Prompt Template + +``` +Please generate a PR summary for the changes made. + +Include: +1. What was changed (high-level) +2. Files created/modified +3. Key decisions and trade-offs +4. Testing/verification steps +5. Checklist of rules enforced + +Format for GitHub PR description. +``` + +--- + +## Example Usage + +### After implementing changes + +``` +Please generate a PR summary for the changes made in this session. + +Include: +1. What was changed (high-level) +2. Files created/modified +3. Key decisions and trade-offs +4. Testing/verification steps +5. Checklist of rules enforced + +Format for GitHub PR description. +``` + +--- + +## Expected Response Format + +```markdown +## Summary + +Added POST /simulate/cascade endpoint for cascading failure simulation. + +## Changes + +### New Files +- `src/cascadeSimulation.js` - Cascade simulation logic + +### Modified Files +- `index.js` - Added endpoint handler +- `src/validator.js` - Added cascade validation +- `README.md` - Documented new endpoint + +## Key Decisions + +- Used existing graph traversal pattern from failureSimulation.js +- Limited serviceIds array to max 10 to prevent timeout +- Graph Engine HTTP API single source + +## Testing + +1. Start server: `npm start` +2. Test endpoint: + ```bash + curl -X POST http://localhost:5000/simulate/cascade \ + -H "Content-Type: application/json" \ + -d '{"serviceIds": ["default:frontend"], "maxDepth": 2}' + ``` +3. Verify response format matches documentation + +## Rules Enforced + +- [x] Graph Engine single source policy +- [x] Timeout pattern preserved +- [x] Credential redaction used +- [x] No CI/CD changes +- [x] Tests added/updated (per Testing Policy) +- [x] Documentation updated +``` + +--- + +## PR Title Patterns + +| Change Type | Title Pattern | +|-------------|---------------| +| New endpoint | `feat(api): add POST /simulate/cascade` | +| Bug fix | `fix(simulation): handle empty serviceIds array` | +| Documentation | `docs: update API reference for scaling endpoint` | +| Configuration | `chore(config): add GRAPH_API_BASE_URL support` | + +--- + +## Checklist Template + +Include this checklist in the PR: + +```markdown +## Checklist + +- [ ] Graph Engine single source policy enforced +- [ ] No credentials in logs +- [ ] Timeout patterns maintained +- [ ] Error handling follows existing patterns +- [ ] Documentation updated +- [ ] No CI/CD changes +- [ ] Tests added/updated (per Testing Policy in `.github/copilot-instructions.md`) +``` diff --git a/.github/prompts/08-post-change-verification.prompt.md b/.github/prompts/08-post-change-verification.prompt.md new file mode 100644 index 0000000..8b4d282 --- /dev/null +++ b/.github/prompts/08-post-change-verification.prompt.md @@ -0,0 +1,361 @@ +--- +name: "Post-Change Verification Audit" +description: "Comprehensive checklist to verify changes comply with governance policies" +--- + +# Post-Change Verification Audit + +Run this audit after making any code changes to ensure compliance with repository governance. + +--- + +## When to Use + +Run this verification: +- After implementing new features +- After fixing bugs +- After refactoring code +- Before creating pull requests +- When reviewing changes from others + +--- + +## Verification Checklist + +### 1) Architecture Compliance + +#### Graph Engine Single Source +```bash +# Verify no direct database drivers in runtime code +git grep -n -E "(require|import).*driver" -- src/ test/ 2>/dev/null | grep -v graphEngine || echo "Clean" + +# Check package.json for forbidden database dependencies (manual check against allowlist) +grep -E '"(pg|mysql2|mongodb|cassandra-driver)":' package.json 2>/dev/null || echo "Clean" + +# Verify no fallback logic in runtime code +git grep -n -E "fallback.*database|alternative.*data.*source" -- src/ test/ 2>/dev/null || echo "Clean" + +# Verify Graph Engine client usage +git grep -n "graphEngineClient\|GraphEngineHttpProvider" src/ +``` + +**Expected results:** +- ✅ Zero direct database access in src/test +- ✅ Zero forbidden dependencies in package.json +- ✅ Zero fallback patterns in runtime code +- ✅ Graph Engine client used for all graph data access + +#### External Service Resilience +```bash +# Verify timeouts are set +git grep -n "timeout:" src/ + +# Verify error handling exists +git grep -n "catch (error)" src/ + +# Verify 503 on service unavailable +git grep -n "503\|SERVICE_UNAVAILABLE" +``` + +**Expected results:** +- ✅ All HTTP requests have explicit timeouts +- ✅ All external calls have try-catch blocks +- ✅ 503 returned when Graph Engine unavailable + +--- + +### 2) Security & Logging + +#### No Credential Exposure +```bash +# Verify redaction in logs +git grep -n "redact\|password\|token" src/ + +# Check for hardcoded credentials (should be none) +git grep -n -E "password.*=.*['\"]|token.*=.*['\"]" + +# Verify env var usage +git grep -n "process.env" +``` + +**Expected results:** +- ✅ Credentials redacted in logs +- ✅ No hardcoded passwords/tokens +- ✅ Secrets loaded from environment variables + +--- + +### 3) API Contract Consistency + +#### OpenAPI Synchronization +```bash +# List modified endpoint files +git diff --name-only HEAD | grep -E "index.js|routes/" + +# Check if openapi.yaml was updated +git diff --name-only HEAD | grep openapi.yaml + +# Verify version bump +git diff openapi.yaml | grep "version:" +``` + +**Required if endpoints changed:** +- ✅ `openapi.yaml` updated +- ✅ Version bumped (patch or minor) +- ✅ Request/response schemas match code + +**Validation (if Swagger enabled):** +```bash +ENABLE_SWAGGER=true npm start & +sleep 2 +curl http://localhost:3000/swagger | grep "swagger" +kill %1 +``` + +--- + +### 4) Testing Coverage + +#### Test Files Updated +```bash +# Check if tests were added/updated +git diff --name-only HEAD | grep "test/" + +# Run tests +npm test + +# Check test coverage (if available) +npm run test:coverage +``` + +**Expected results:** +- ✅ Tests exist for new/modified behavior +- ✅ All tests passing +- ✅ Coverage maintained or improved + +#### Test Scenarios Covered +For behavioral changes, verify tests cover: +- [ ] Happy path (success case) +- [ ] Graph Engine unavailable (503) +- [ ] Timeout scenario +- [ ] Invalid input (400) +- [ ] Upstream error (502) + +--- + +### 5) Documentation Synchronization + +#### Docs Updated +```bash +# Check for modified docs +git diff --name-only HEAD | grep -E "\.md$|docs/" + +# Verify docs mention new features +git diff README.md DEPLOYMENT.md docs/ +``` + +**Required updates when:** +- New endpoint added → Update README.md +- New env var required → Update DEPLOYMENT.md +- New workflow → Update docs/COPILOT-USAGE-GUIDE.md +- Policy change → Update .github/copilot-instructions.md + +--- + +### 6) Governance File Consistency + +#### Instruction/Prompt/Skill Files +```bash +# Check for broken references +git grep -n "\.github/.*\.md" .github/ + +# Check file structure +ls -la .github/instructions/ +ls -la .github/prompts/ +ls -la .github/skills/ +``` + +**Expected results:** +- ✅ No references to removed files +- ✅ All referenced files exist +- ✅ File numbering is sequential (instructions/prompts) + +--- + +### 7) Code Quality + +#### Linting & Formatting +```bash +# Run linter (if configured) +npm run lint + +# Check for console.log (should use logger) +git grep -n "console\\.log" src/ + +# Check for TODO/FIXME comments +git grep -n "TODO\|FIXME" src/ +``` + +**Expected results:** +- ✅ No linting errors +- ✅ No console.log in production code (use logger) +- ✅ TODOs tracked or removed + +--- + +## Regression Scan Commands + +### Quick Scan (Essential) +```bash +#!/bin/bash +echo "=== Regression Scan ===" + +echo "1. Direct database drivers in runtime code..." +git grep -n -E "(require|import).*driver" -- src/ test/ 2>/dev/null | grep -v graphEngine && echo "❌ FAIL" || echo "✅ PASS" + +echo "2. Fallback logic in runtime code..." +git grep -n -i "fallback.*database" -- src/ test/ 2>/dev/null && echo "❌ FAIL" || echo "✅ PASS" + +echo "3. Tests..." +npm test && echo "✅ PASS" || echo "❌ FAIL" + +echo "4. OpenAPI sync..." +git diff --name-only HEAD | grep -E "index.js|routes/" >/dev/null && \ + git diff --name-only HEAD | grep openapi.yaml >/dev/null && \ + echo "✅ PASS" || echo "⚠️ WARNING: Endpoints changed but openapi.yaml not updated" +``` + +### Full Audit (Comprehensive) +```bash +#!/bin/bash +echo "=== Full Governance Audit ===" + +# Architecture +echo "Architecture Compliance:" +echo "- Direct DB drivers (runtime): $(git grep -c -E '(require|import).*driver' -- src/ test/ 2>/dev/null | grep -v graphEngine | wc -l || echo 0)" +echo "- Fallback patterns (runtime): $(git grep -c -i 'fallback.*database' -- src/ test/ 2>/dev/null || echo 0)" +echo "- Graph Engine usage: $(git grep -c 'graphEngineClient' src/ 2>/dev/null || echo 0)" + +# Security +echo "Security Checks:" +echo "- Credential redaction: $(git grep -c 'redact' src/ 2>/dev/null || echo 0)" +echo "- Hardcoded secrets: $(git grep -c -E "password.*=.*['\"]" src/ 2>/dev/null || echo 0)" + +# Testing +echo "Testing:" +npm test 2>&1 | grep -E "passing|failing" + +# Docs +echo "Documentation:" +echo "- Modified docs: $(git diff --name-only HEAD | grep -c '\.md$' || echo 0)" +``` + +--- + +## Automated Verification Script + +Save as `scripts/verify-changes.sh`: + +```bash +#!/bin/bash +set -e + +echo "🔍 Running post-change verification..." + +# 1. Architecture +echo "📋 Checking architecture compliance..." +if git grep -q -E "(require|import).*driver" -- src/ test/ 2>/dev/null | grep -qv graphEngine; then + echo "❌ Direct database drivers found in runtime code!" + git grep -n -E "(require|import).*driver" -- src/ test/ | grep -v graphEngine + exit 1 +fi + +if git grep -q -i "fallback.*database" -- src/ test/ 2>/dev/null; then + echo "⚠️ Fallback logic detected - verify compliance" + git grep -n -i "fallback.*database" -- src/ test/ +fi + +# 2. Tests +echo "🧪 Running tests..." +npm test + +# 3. OpenAPI sync +if git diff --name-only HEAD | grep -q -E "index.js|routes/"; then + if ! git diff --name-only HEAD | grep -q "openapi.yaml"; then + echo "⚠️ WARNING: Endpoints changed but openapi.yaml not updated" + echo " See .github/copilot-instructions.md §0.4" + fi +fi + +# 4. Security +echo "🔒 Checking security..." +if git grep -q -E "password.*=.*['\"]|token.*=.*['\"]" src/; then + echo "❌ Hardcoded credentials found!" + git grep -n -E "password.*=.*['\"]|token.*=.*['\"]" src/ + exit 1 +fi + +echo "✅ Verification complete!" +``` + +Make executable: +```bash +chmod +x scripts/verify-changes.sh +``` + +--- + +## Pass Criteria + +Changes are ready to commit when: + +- [x] No direct database access in runtime code (src/test) +- [x] No fallback logic to alternative data sources +- [x] All tests passing +- [x] OpenAPI spec updated (if endpoints changed) +- [x] Documentation updated (if behavior changed) +- [x] No hardcoded credentials +- [x] Timeouts set on all HTTP requests +- [x] Error handling follows standard patterns +- [x] No broken references in .github/ files + +--- + +## Failure Response + +If verification fails: + +1. **Identify root cause** — Which check failed? +2. **Review policy** — Read relevant .github/instructions/ file +3. **Fix violation** — Update code to comply +4. **Re-run verification** — Ensure all checks pass +5. **Document** — If intentional deviation, document why + +--- + +## Integration with Pull Requests + +Add to PR template (`.github/pull_request_template.md`): + +```markdown +## Pre-Merge Verification + +- [ ] Ran `scripts/verify-changes.sh` (all checks passed) +- [ ] No direct database drivers in src/test: scan imports/requires manually +- [ ] Tests passing: `npm test` +- [ ] OpenAPI updated (if endpoints changed) +- [ ] Docs updated (if behavior changed) +``` + +--- + +## Quick Reference + +| Check | Command | Expected | +|-------|---------|----------| +| Direct DB drivers | Manual scan of imports/requires | Zero forbidden drivers | +| Fallback logic | `git grep -i "fallback.*database" src/ test/` | Zero matches | +| Tests | `npm test` | All passing | +| OpenAPI sync | `git diff openapi.yaml` | Updated if endpoints changed | +| Credentials | `git grep -E "password.*="` | Zero matches | +| Timeouts | `git grep timeout: src/` | Present in all HTTP calls | diff --git a/.github/skills/graph-api-client/SKILL.md b/.github/skills/graph-api-client/SKILL.md new file mode 100644 index 0000000..c9a45c6 --- /dev/null +++ b/.github/skills/graph-api-client/SKILL.md @@ -0,0 +1,162 @@ +--- +name: graph-api-client +description: Guide for consuming the leader-owned Graph API service. Use this when asked to fetch graph data, integrate with Graph API, or understand API consumption patterns. +license: MIT +--- + +# Graph API Client Skill + +This skill helps you consume the leader-owned Graph API service correctly in the predictive analysis engine. + +## When to Use This Skill + +Use this skill when you need to: +- Fetch microservice topology data +- Integrate with the Graph API service +- Understand the API-first architecture +- Add new Graph API consumption patterns + +## Critical Constraints + +### Graph Engine API Only Policy +Graph Engine HTTP API is the single source of truth: +1. **Use Graph Engine API** for all graph data +2. **No fallback** — If unavailable, return 503 +3. **No alternatives** — No direct database access permitted + +### Contract Discipline +- **Never invent endpoints** — Only use documented endpoints +- **Never invent request/response shapes** — Follow existing contracts +- **If contract is missing** — Ask the user or point out the gap +- **Require env var** — `GRAPH_API_BASE_URL` must be set + +## Configuration + +```javascript +// src/config.js pattern +const config = { + graphApi: { + baseUrl: process.env.GRAPH_API_BASE_URL || 'http://graph-api:8080', + timeout: parseInt(process.env.GRAPH_API_TIMEOUT) || 30000, + } +}; +``` + +## Client Pattern + +### Basic HTTP Client +```javascript +const axios = require('axios'); +const config = require('./config'); + +async function fetchFromGraphApi(endpoint, params = {}) { + const url = `${config.graphApi.baseUrl}${endpoint}`; + + try { + const response = await axios.get(url, { + params, + timeout: config.graphApi.timeout, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + return response.data; + } catch (error) { + if (error.response) { + // Server responded with error + logger.error('Graph Engine error', { + status: error.response.status, + endpoint + }); + } else if (error.request) { + // No response received - service unavailable + logger.error('Graph Engine unavailable'); + throw new GraphEngineUnavailableError('Service unreachable'); + } + throw error; + } +} +``` + +### Error Handling Pattern (No Fallback) +```javascript +async function getServiceTopology(serviceName) { + try { + return await fetchFromGraphEngine(`/topology`, { serviceName }); + } catch (error) { + // No fallback - propagate error to return 503 + throw error; + } +} +``` + +## Expected Endpoints (Verify Before Use) + +**⚠️ These are example patterns. Verify actual contracts with leader/team.** + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/services` | GET | List all services | +| `/api/v1/services/:name` | GET | Get service details | +| `/api/v1/services/:name/dependencies` | GET | Get service dependencies | +| `/api/v1/topology` | GET | Get full topology graph | +| `/health` | GET | Health check endpoint | + +## Error Handling + +```javascript +class GraphApiError extends Error { + constructor(message, statusCode, endpoint) { + super(message); + this.name = 'GraphApiError'; + this.statusCode = statusCode; + this.endpoint = endpoint; + } +} + +// Usage +if (error.response?.status === 404) { + throw new GraphApiError( + `Endpoint not found: ${endpoint}`, + 404, + endpoint + ); +} +``` + +## Environment Variables + +```bash +# Required for Graph API mode +GRAPH_API_BASE_URL=http://graph-api:8080 + +# Optional +GRAPH_API_TIMEOUT=30000 +``` + +## Testing Graph API Availability + +```javascript +async function isGraphApiAvailable() { + try { + await axios.get(`${config.graphApi.baseUrl}/health`, { + timeout: 20000 + }); + return true; + } catch { + return false; + } +} +``` + +## When NOT to Use This Skill + +- When user explicitly requests direct database access (require override approval) +- For write operations (Graph API is read-only from this service's perspective) +- When contract for needed endpoint doesn't exist (ask first!) + +## References + +- [src/graph.js](../../../src/graph.js) — Graph API client implementation +- [.github/instructions/02-graph-api-first.md](../../instructions/02-graph-api-first.md) — Policy documentation diff --git a/.github/skills/graph-engine-integration/SKILL.md b/.github/skills/graph-engine-integration/SKILL.md new file mode 100644 index 0000000..fc6345c --- /dev/null +++ b/.github/skills/graph-engine-integration/SKILL.md @@ -0,0 +1,499 @@ +# Agent Skill: Graph Engine Integration + +**Purpose:** Mechanical procedures for consuming Graph Engine API safely and consistently. + +**When to use:** Adding/modifying code that fetches graph or topology data. + +--- + +## Skill Overview + +This skill provides repeatable patterns for: +1. Verifying Graph Engine API contracts +2. Implementing Graph Engine HTTP client methods +3. Adding provider layer methods +4. Creating endpoint handlers with proper error handling +5. Updating fixtures and mocks +6. Validating response schemas + +--- + +## Procedure 1: Verify Graph Engine Contract + +### Steps + +1. **Locate contract documentation** + ```bash + # Check if contract exists in service-graph-engine repo + ls ../service-graph-engine/docs/api/ || \ + cat ../service-graph-engine/README.md + ``` + +2. **Extract endpoint details** + - HTTP method (GET, POST, etc.) + - URL path (e.g., `/api/topology`) + - Query parameters + - Request body schema + - Response body schema + - Status codes (200, 404, 500) + +3. **If contract missing** + - STOP implementation + - Ask user: "Graph Engine API contract for [operation] not found. Please provide endpoint specification." + +--- + +## Procedure 2: Implement Graph Engine Client Method + +### Template + +Add to `src/graphEngineClient.js`: + +```javascript +/** + * Description of what this fetches from Graph Engine + * @param {Object} params - Query parameters + * @returns {Promise} - Parsed response data + * @throws {GraphEngineUnavailableError} - When service is down + */ +async getResourceName(params = {}) { + const endpoint = '/api/resource'; // Verified endpoint path + + try { + const response = await this.client.get(endpoint, { + params, + timeout: this.timeout, + headers: { + 'Accept': 'application/json' + } + }); + + logger.debug('Graph Engine request succeeded', { + endpoint, + params, + statusCode: response.status + }); + + return response.data; + } catch (error) { + logger.error('Graph Engine request failed', { + endpoint, + params: redactSensitiveParams(params), + error: error.message, + code: error.code, + statusCode: error.response?.status + }); + + throw this.handleError(error); + } +} +``` + +### Checklist + +- [ ] Method name is descriptive (e.g., `getTopology`, not `getData`) +- [ ] JSDoc comment explains purpose and throws +- [ ] Endpoint path is verified against contract +- [ ] Timeout is set explicitly (`this.timeout`) +- [ ] Error is logged (without credentials) +- [ ] Error is classified via `handleError()` +- [ ] No fallback logic + +--- + +## Procedure 3: Update Provider Layer + +### Template + +Add to `src/providers/GraphEngineHttpProvider.js`: + +```javascript +/** + * Description matching client method + * @param {Object} params - Parameters + * @returns {Promise} - Data + */ +async getResourceName(params) { + try { + return await this.client.getResourceName(params); + } catch (error) { + // No fallback - propagate error + throw error; + } +} +``` + +### Checklist + +- [ ] Method signature matches use case +- [ ] Delegates to `this.client` (GraphEngineClient) +- [ ] No transformation (unless required by contract) +- [ ] No fallback logic +- [ ] Error propagates to caller + +--- + +## Procedure 4: Add Endpoint Handler + +### Template + +Add to `index.js`: + +```javascript +/** + * GET /api/endpoint-name + * Description of what endpoint does + */ +app.get('/api/endpoint-name', async (req, res) => { + try { + // Validate input (if needed) + const params = { + serviceId: req.query.serviceId, + // ... other params + }; + + // Fetch from Graph Engine + const data = await graphProvider.getResourceName(params); + + // Return success + res.json(data); + } catch (error) { + // Classify error and return appropriate status + if (error.code === 'GRAPH_ENGINE_UNAVAILABLE') { + return res.status(503).json({ + error: 'Graph Engine unavailable', + code: 'GRAPH_ENGINE_UNAVAILABLE', + message: 'Unable to fetch resource', + retryable: true + }); + } else if (error.response?.status === 404) { + return res.status(404).json({ + error: 'Resource not found', + code: 'NOT_FOUND' + }); + } else if (error.response?.status >= 500) { + return res.status(502).json({ + error: 'Upstream error', + code: 'GRAPH_ENGINE_ERROR', + message: error.message + }); + } else { + return res.status(500).json({ + error: 'Internal server error', + message: error.message + }); + } + } +}); +``` + +### Checklist + +- [ ] Route path follows REST conventions +- [ ] Input validation (if needed) +- [ ] Calls provider method (not client directly) +- [ ] Returns 503 when Graph Engine unavailable +- [ ] Returns 404 when resource not found +- [ ] Returns 502 for upstream errors +- [ ] Error responses are structured (error, code, message) +- [ ] No silent failures + +--- + +## Procedure 5: Update Fixtures and Mocks + +### For Tests + +Create mock in `test/fixtures/graph-engine-responses.js`: + +```javascript +module.exports = { + topology: { + nodes: [ + { id: 'svc-1', name: 'service-a' }, + { id: 'svc-2', name: 'service-b' } + ], + edges: [ + { source: 'svc-1', target: 'svc-2', metrics: {...} } + ] + }, + + resourceName: { + // Example response structure + id: 'res-1', + data: {...} + } +}; +``` + +### In Test File + +```javascript +const nock = require('nock'); +const fixtures = require('./fixtures/graph-engine-responses'); + +test('endpoint returns data when Graph Engine available', async () => { + // Mock Graph Engine response + nock('http://service-graph-engine:3000') + .get('/api/resource') + .query({ serviceId: 'svc-1' }) + .reply(200, fixtures.resourceName); + + const response = await request(app) + .get('/api/endpoint-name?serviceId=svc-1'); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(response.body, fixtures.resourceName); +}); + +test('endpoint returns 503 when Graph Engine unavailable', async () => { + // Mock connection failure + nock('http://service-graph-engine:3000') + .get('/api/resource') + .replyWithError({ code: 'ECONNREFUSED' }); + + const response = await request(app) + .get('/api/endpoint-name?serviceId=svc-1'); + + assert.strictEqual(response.status, 503); + assert.strictEqual(response.body.code, 'GRAPH_ENGINE_UNAVAILABLE'); + assert.strictEqual(response.body.retryable, true); +}); +``` + +### Checklist + +- [ ] Fixtures match real Graph Engine response structure +- [ ] Tests cover success path (200) +- [ ] Tests cover service unavailable (503) +- [ ] Tests cover timeout scenario +- [ ] Tests cover upstream error (500 → 502) +- [ ] Tests cover not found (404) +- [ ] Nock mocks use correct URL and path + +--- + +## Procedure 6: Validate Response Schemas + +### Manual Validation + +```bash +# Start Graph Engine (if available locally) +cd ../service-graph-engine && npm start & + +# Test actual endpoint +curl -v http://localhost:3000/api/resource?serviceId=svc-1 | jq . + +# Compare with fixture +diff <(curl -s http://localhost:3000/api/resource | jq -S .) \ + <(cat test/fixtures/graph-engine-responses.js | jq -S .resource) +``` + +### Automated Schema Validation (if available) + +```javascript +const Ajv = require('ajv'); +const ajv = new Ajv(); + +const schema = { + type: 'object', + required: ['nodes', 'edges'], + properties: { + nodes: { type: 'array' }, + edges: { type: 'array' } + } +}; + +const validate = ajv.compile(schema); +const valid = validate(response.data); + +if (!valid) { + console.error('Schema validation failed:', validate.errors); +} +``` + +--- + +## Procedure 7: Update OpenAPI Spec + +### Add Endpoint + +Edit `openapi.yaml`: + +```yaml +paths: + /api/endpoint-name: + get: + summary: Brief description + operationId: getResourceName + tags: + - graph-engine + parameters: + - name: serviceId + in: query + required: true + schema: + type: string + description: Service identifier + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ResourceResponse' + '404': + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: Graph Engine unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceUnavailableError' +``` + +### Add Schema Component + +```yaml +components: + schemas: + ResourceResponse: + type: object + required: + - id + - data + properties: + id: + type: string + data: + type: object +``` + +### Bump Version + +```yaml +info: + version: 1.2.3 # Increment patch or minor +``` + +### Checklist + +- [ ] Endpoint added to `paths:` +- [ ] All parameters documented +- [ ] All status codes documented (200, 404, 503, etc.) +- [ ] Response schemas defined in `components/schemas:` +- [ ] Version bumped +- [ ] Swagger UI validates (if enabled) + +--- + +## Final Verification Commands + +### 1. Code Scan +```bash +# No direct database drivers in runtime code +git grep -n -E "(require|import).*driver" -- src/ test/ | grep -v graphEngine + +# No fallback logic +git grep -n -i "fallback" src/ + +# Verify Graph Engine client usage +git grep -n "graphEngineClient" src/ +``` + +### 2. Test Execution +```bash +npm test +``` + +### 3. OpenAPI Validation +```bash +# Install validator (if not installed) +npm install -g @apidevtools/swagger-cli + +# Validate spec +swagger-cli validate openapi.yaml +``` + +### 4. Manual Testing +```bash +# Start service +npm start & + +# Test new endpoint +curl -v http://localhost:3000/api/endpoint-name?serviceId=svc-1 + +# Test error case (stop Graph Engine first) +curl -v http://localhost:3000/api/endpoint-name?serviceId=svc-1 +# Should return 503 + +# Cleanup +kill %1 +``` + +--- + +## Common Patterns + +### Pattern: Timeout Configuration +```javascript +const timeout = config.GRAPH_API_TIMEOUT_MS || 20000; +``` + +### Pattern: Error Classification +```javascript +handleError(error) { + if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') { + throw new GraphEngineUnavailableError(error); + } else if (error.response?.status >= 500) { + throw new GraphEngineUpstreamError(error); + } else { + throw error; + } +} +``` + +### Pattern: Credential Redaction +```javascript +function redactSensitiveParams(params) { + const redacted = { ...params }; + if (redacted.apiKey) redacted.apiKey = '***'; + if (redacted.token) redacted.token = '***'; + return redacted; +} +``` + +--- + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Correct Approach | +|--------------|---------|------------------| +| `if (!graphEngine) { useDirectDB() }` | Fallback violates policy | Return 503 | +| No timeout | Hangs indefinitely | Set explicit timeout | +| Swallow errors | Hides failures | Propagate to caller | +| Invent endpoint | Contract violation | Verify endpoint exists | +| Skip tests | No regression safety | Add success + failure tests | +| Hardcode URL | Not configurable | Use env var | + +--- + +## Success Criteria + +Integration is complete when: + +- [x] Contract verified (endpoint exists in Graph Engine docs) +- [x] Client method added with timeout and error handling +- [x] Provider method added (no fallback) +- [x] Endpoint handler added with 503 error handling +- [x] Tests added (success + failure scenarios) +- [x] Fixtures/mocks updated +- [x] OpenAPI spec updated and validated +- [x] Documentation updated +- [x] Verification commands pass +- [x] No direct database access introduced +- [x] No fallback logic added diff --git a/.github/skills/k8s-deployment/SKILL.md b/.github/skills/k8s-deployment/SKILL.md new file mode 100644 index 0000000..91161d7 --- /dev/null +++ b/.github/skills/k8s-deployment/SKILL.md @@ -0,0 +1,231 @@ +--- +name: k8s-deployment +description: Guide for Kubernetes deployment using Minikube. Use this when asked about deployment, Kubernetes manifests, or local k8s testing. +license: MIT +--- + +# Kubernetes Deployment Skill + +This skill helps you work with Kubernetes deployments for the predictive analysis engine, specifically targeting Minikube for local development. + +## When to Use This Skill + +Use this skill when you need to: +- Deploy the application to Minikube +- Modify Kubernetes manifests +- Debug deployment issues +- Understand the k8s architecture + +## Deployment Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Minikube │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ analysis │────▶│ Graph Engine │ │ +│ │ -engine │ │ HTTP API │ │ +│ │ (Deployment) │ │ (external) │ │ +│ └─────────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +## File Structure + +``` +k8s/ +└── base/ + ├── kustomization.yaml # Kustomize configuration + ├── deployment.yaml # Main deployment + └── service.yaml # Service exposure +``` + +## Quick Commands + +### Deploy to Minikube +```bash +# Start Minikube (if not running) +minikube start + +# Build image in Minikube's Docker +eval $(minikube docker-env) +docker build -t predictive-analysis-engine:local . + +# Apply manifests +kubectl apply -k k8s/base/ + +# Verify deployment +kubectl get pods -l app=analysis-engine +kubectl get svc analysis-engine +``` + +### Access the Service +```bash +# Port forward for local access +kubectl port-forward svc/analysis-engine 3000:3000 + +# Or use Minikube service +minikube service analysis-engine --url +``` + +### View Logs +```bash +kubectl logs -l app=analysis-engine -f +``` + +### Delete Deployment +```bash +kubectl delete -k k8s/base/ +``` + +## Deployment Manifest Pattern + +```yaml +# k8s/base/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: analysis-engine + labels: + app: analysis-engine +spec: + replicas: 1 + selector: + matchLabels: + app: analysis-engine + template: + metadata: + labels: + app: analysis-engine + spec: + containers: + - name: analysis-engine + image: predictive-analysis-engine:local + imagePullPolicy: Never # Use local image + ports: + - containerPort: 3000 + env: + - name: PORT + value: "3000" + - name: SERVICE_GRAPH_ENGINE_URL + valueFrom: + configMapKeyRef: + name: graph-engine-config + key: base-url + - name: GRAPH_API_TIMEOUT_MS + value: "20000" + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 +``` + +## Service Manifest Pattern + +```yaml +# k8s/base/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: analysis-engine +spec: + selector: + app: analysis-engine + ports: + - port: 3000 + targetPort: 3000 + type: ClusterIP +``` + +## Secrets Management + +### Create Graph Engine ConfigMap +```bash +kubectl create configmap graph-engine-config \ + --from-literal=base-url=http://service-graph-engine:3000 +``` + +## Configuration Management + +All external service configuration is stored in ConfigMaps (not Secrets, as URLs are not sensitive): + +## Kustomize Pattern + +```yaml +# k8s/base/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - deployment.yaml + - service.yaml + +commonLabels: + app.kubernetes.io/name: analysis-engine + app.kubernetes.io/component: backend +``` + +## Troubleshooting + +### Pod Not Starting +```bash +# Check pod status +kubectl describe pod -l app=analysis-engine + +# Check events +kubectl get events --sort-by='.lastTimestamp' +``` + +### Connection to Graph Engine Failing +```bash +# Verify Graph Engine is reachable from pod +kubectl exec -it -- nc -zv service-graph-engine 3000 + +# Check config is mounted +kubectl exec -it -- env | grep GRAPH + +# Test HTTP connectivity +kubectl exec -it -- wget -O- http://service-graph-engine:3000/health +``` + +### Image Not Found +```bash +# Ensure using Minikube's Docker +eval $(minikube docker-env) +docker images | grep analysis-engine + +# Rebuild if needed +docker build -t predictive-analysis-engine:local . +``` + +## Scope Limitations + +**This project's k8s scope is LIMITED to Minikube for local development.** + +❌ NOT in scope: +- Production cluster deployments +- Helm charts +- Cloud-specific configurations (EKS, GKE, AKS) +- Service mesh configurations +- Ingress controllers (beyond basic) + +## References + +- [k8s/base/deployment.yaml](../../../k8s/base/deployment.yaml) +- [k8s/base/service.yaml](../../../k8s/base/service.yaml) +- [DEPLOYMENT.md](../../../DEPLOYMENT.md) — Deployment documentation +- [Dockerfile](../../../Dockerfile) — Container build diff --git a/.github/skills/simulation-runner/SKILL.md b/.github/skills/simulation-runner/SKILL.md new file mode 100644 index 0000000..c758772 --- /dev/null +++ b/.github/skills/simulation-runner/SKILL.md @@ -0,0 +1,214 @@ +--- +name: simulation-runner +description: Guide for running and understanding predictive analysis simulations. Use this when asked to simulate failures, scaling scenarios, or understand simulation logic. +license: MIT +--- + +# Simulation Runner Skill + +This skill helps you work with the predictive analysis engine's core functionality — simulating failure and scaling scenarios for microservice architectures. + +## When to Use This Skill + +Use this skill when you need to: +- Understand how simulations work +- Add new simulation scenarios +- Debug simulation logic +- Extend failure or scaling simulation capabilities + +## Simulation Types + +### 1. Failure Simulation +Simulates what happens when a service fails completely or partially. + +**Input:** +```json +{ + "serviceName": "payment-service", + "failureType": "complete", // or "partial" + "failureRate": 1.0 // 0.0 to 1.0 +} +``` + +**Output:** +```json +{ + "affectedServices": ["order-service", "checkout-service"], + "cascadeDepth": 2, + "estimatedImpact": { + "errorRateIncrease": 0.45, + "latencyIncrease": 250 + } +} +``` + +### 2. Scaling Simulation +Simulates the effect of scaling a service up or down. + +**Input:** +```json +{ + "serviceName": "api-gateway", + "currentReplicas": 3, + "targetReplicas": 6, + "expectedLoad": 1.5 // multiplier +} +``` + +**Output:** +```json +{ + "scalingRecommendation": "proceed", + "estimatedCapacity": { + "requestsPerSecond": 15000, + "headroom": 0.25 + }, + "downstreamImpact": [] +} +``` + +## Core Files + +| File | Purpose | +|------|---------| +| `src/failureSimulation.js` | Failure scenario logic | +| `src/scalingSimulation.js` | Scaling scenario logic | +| `src/graph.js` | Fetches topology data | +| ~~legacy direct-db module~~ | (removed) | + +## Simulation Flow + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Request │────▶│ Validate │────▶│ Fetch Graph │ +│ /simulate │ │ Input │ │ Topology │ +└──────────────┘ └──────────────┘ └──────────────┘ + │ + ┌──────────────┐ │ + │ Return │◀───────────┤ + │ Results │ ┌──────▼───────┐ + └──────────────┘ │ Run │ + │ Simulation │ + └──────────────┘ +``` + +## Adding a New Simulation Type + +### Step 1: Create Simulation Module +```javascript +// src/newSimulation.js +async function simulateNewScenario(params, topology) { + // 1. Validate params + validateParams(params); + + // 2. Extract relevant graph data + const affectedNodes = findAffectedNodes(topology, params); + + // 3. Run simulation logic + const results = calculateImpact(affectedNodes, params); + + // 4. Return structured results + return { + scenario: 'new-scenario', + input: params, + results, + timestamp: new Date().toISOString() + }; +} +``` + +### Step 2: Register Endpoint +```javascript +// index.js +app.post('/api/simulate/new-scenario', async (req, res) => { + try { + const topology = await getTopology(); + const result = await simulateNewScenario(req.body, topology); + res.json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); +``` + +## Testing Simulations Locally + +```bash +# Start the server +npm start + +# Run a failure simulation +curl -X POST http://localhost:3000/api/simulate/failure \ + -H "Content-Type: application/json" \ + -d '{"serviceName": "payment-service", "failureType": "complete"}' + +# Run a scaling simulation +curl -X POST http://localhost:3000/api/simulate/scaling \ + -H "Content-Type: application/json" \ + -d '{"serviceName": "api-gateway", "currentReplicas": 3, "targetReplicas": 6}' +``` + +## Validation Rules + +All simulation inputs must be validated: + +```javascript +function validateFailureParams(params) { + if (!params.serviceName) { + throw new Error('serviceName is required'); + } + if (params.failureRate && (params.failureRate < 0 || params.failureRate > 1)) { + throw new Error('failureRate must be between 0 and 1'); + } +} +``` + +## Graph Traversal Patterns + +### Find Downstream Services (Cascade Analysis) +```javascript +function findDownstreamServices(topology, serviceName, depth = 3) { + const visited = new Set(); + const queue = [{ name: serviceName, level: 0 }]; + const downstream = []; + + while (queue.length > 0) { + const { name, level } = queue.shift(); + if (visited.has(name) || level > depth) continue; + + visited.add(name); + const service = topology.services.find(s => s.name === name); + + if (service?.dependencies) { + for (const dep of service.dependencies) { + downstream.push({ name: dep, level: level + 1 }); + queue.push({ name: dep, level: level + 1 }); + } + } + } + + return downstream; +} +``` + +### Find Upstream Services (Impact Analysis) +```javascript +function findUpstreamServices(topology, serviceName) { + return topology.services.filter(s => + s.dependencies?.includes(serviceName) + ); +} +``` + +## Performance Considerations + +- **Cache topology data** — Don't re-fetch for every simulation +- **Limit cascade depth** — Default to 3-5 levels max +- **Use timeouts** — All external calls should timeout +- **Batch calculations** — Process multiple services in parallel when possible + +## References + +- [src/failureSimulation.js](../../../src/failureSimulation.js) — Failure simulation +- [src/scalingSimulation.js](../../../src/scalingSimulation.js) — Scaling simulation +- [test/simulation.test.js](../../../test/simulation.test.js) — Test examples diff --git a/.gitignore b/.gitignore index a312cf5..1609839 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules *.log .DS_Store +data/ diff --git a/.spectral.yaml b/.spectral.yaml new file mode 100644 index 0000000..ada89e0 --- /dev/null +++ b/.spectral.yaml @@ -0,0 +1,14 @@ +# Spectral OpenAPI Linter Configuration +# See: https://docs.stoplight.io/docs/spectral/ + +extends: + - "spectral:oas" + +# Custom rule severity overrides +rules: + # Ensure all operations have unique operationId (critical for SDK generation) + operation-operationId-unique: error + # Ensure all operations have descriptions (important for documentation) + operation-description: warn + # Ensure all operations have operationId (critical for SDK generation) + operation-operationId: warn diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d785bf6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,90 @@ +{ + // =========================================== + // GitHub Copilot Configuration + // =========================================== + + // Enable custom instructions from .github/copilot-instructions.md + "github.copilot.chat.codeGeneration.useInstructionFiles": true, + + // Locations for path-specific instruction files + "chat.instructionsFilesLocations": { + ".github/instructions": true + }, + + // Locations for reusable prompt files + "chat.promptFilesLocations": { + ".github/prompts": true + }, + + // Enable AGENTS.md file support + "chat.useAgentsMdFile": true, + + // Enable Agent Skills (experimental - requires VS Code Insiders) + "chat.useAgentSkills": true, + + // =========================================== + // Agent Mode Configuration + // =========================================== + + // Enable agent mode + "chat.agent.enabled": true, + + // Maximum requests per agent session + "chat.agent.maxRequests": 25, + + // Auto-fix issues in generated code + "github.copilot.chat.agent.autoFix": true, + + // Summarize conversation history when context is full + "github.copilot.chat.summarizeAgentConversationHistory.enabled": true, + + // =========================================== + // Terminal Command Safety + // =========================================== + + // Enable auto-approve for safe commands + "chat.tools.terminal.enableAutoApprove": true, + + // Whitelist safe commands, block dangerous ones + "chat.tools.terminal.autoApprove": { + "npm": true, + "node": true, + "git": true, + "cat": true, + "ls": true, + "echo": true, + "rm": false, + "rmdir": false, + "del": false, + "kill": false, + "curl": false, + "wget": false, + "eval": false, + "chmod": false, + "chown": false + }, + + // CRITICAL: Never auto-approve all tools (security risk) + "chat.tools.global.autoApprove": false, + + // =========================================== + // Inline Suggestions + // =========================================== + + // Enable inline suggestions for all languages + "github.copilot.enable": { + "*": true, + "plaintext": false, + "markdown": true, + "scminput": false + }, + + // Enable next edit suggestions + "github.copilot.nextEditSuggestions.enabled": true, + + // =========================================== + // Code Review (Experimental) + // =========================================== + + "github.copilot.chat.reviewSelection.enabled": true +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..52d1198 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,191 @@ +# AGENTS.md — predictive-analysis-engine + +This file provides universal agent instructions compatible with GitHub Copilot coding agent, OpenAI Codex, Claude, and any agent following the [openai/agents.md](https://github.com/openai/agents.md) standard. + +--- + +## Project Overview + +**What this is:** A predictive analysis engine for microservice call graphs. It analyzes microservice topologies and simulates failure/scaling scenarios to predict system behavior. + +**Tech Stack:** +- **Runtime:** Node.js (CommonJS) +- **Framework:** Express.js +- **Data Source:** Graph Engine HTTP API (service-graph-engine) +- **External Dependency:** Graph API consumed via HTTP + +**Key Files:** +- `index.js` — Main entry point, Express server setup +- `src/graphEngineClient.js` — Graph Engine HTTP client +- `src/providers/GraphEngineHttpProvider.js` — Graph data provider +- `src/failureSimulation.js` — Failure scenario simulation logic +- `src/scalingSimulation.js` — Scaling scenario simulation logic +- `src/config.js` — Environment configuration +- `src/validator.js` — Request validation + +--- + +## Commands + +### Install Dependencies +```bash +npm install +``` + +### Run the Application +```bash +npm start +``` +Server starts on port defined by `PORT` env var (default: 5000). + +### Run Tests +```bash +npm test +``` +Uses Node.js built-in test runner. + +### Environment Variables Required +```bash +# Required +SERVICE_GRAPH_ENGINE_URL=http://service-graph-engine:3000 +# or: GRAPH_ENGINE_BASE_URL=http://service-graph-engine:3000 + +# Optional +PORT=5000 +GRAPH_API_TIMEOUT_MS=20000 +``` + +--- + +## Boundaries (Critical) + +### ✅ ALWAYS DO +- Use Graph Engine HTTP API for all graph data access +- Follow the plan-first workflow: inventory → plan → questions → wait for approval +- Provide evidence (file path + snippet) when stating facts +- **Add/update tests** for behavioral changes when test framework exists (see Testing Policy in `.github/copilot-instructions.md`) +- **Update relevant docs** when behavior/config/API changes +- **Update governance files** when workflows/standards are impacted +- **Update `openapi.yaml`** for any API add/change/removal (see `.github/copilot-instructions.md` §0.4) + +### ⚠️ ASK FIRST +- Before consuming a new Graph API endpoint (verify contract exists) +- Before modifying any existing simulation logic +- Before adding new dependencies + +### 🚫 NEVER DO +- Add CI/CD workflows (`.github/workflows/*`) +- Add or modify tests without explicit approval +- Log secrets, passwords, or connection strings +- Invent Graph API endpoints or request/response shapes +- Implement without user typing `OK IMPLEMENT NOW` + +--- + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ +│ HTTP Client │────▶│ Express API │────▶│ Graph Engine │ +└─────────────────┘ └─────────────────┘ │ HTTP API │ + └─────────────────┘ +``` + +### Data Flow Priority +1. **Graph Engine API** — Single source of truth for topology and metrics + +--- + +## File Structure + +``` +├── index.js # Express server entry point +├── package.json # Dependencies and scripts +├── src/ +│ ├── config.js # Environment configuration +│ ├── failureSimulation.js # Failure scenario logic +│ ├── scalingSimulation.js # Scaling scenario logic +│ ├── graphEngineClient.js # Graph Engine HTTP client +│ ├── providers/ # Graph data provider layer +│ │ ├── GraphDataProvider.js +│ │ ├── GraphEngineHttpProvider.js +│ │ └── index.js +│ └── validator.js # Request validation +├── .github/ +│ ├── copilot-instructions.md # Master Copilot instruction file +│ ├── agents/ +│ │ ├── planner.agent.md # Plan-first workflow agent +│ │ ├── implementer.agent.md # Code execution agent (requires approval) +│ │ └── reviewer.agent.md # Change validation agent +│ ├── prompts/ +│ │ ├── 01-plan-change.prompt.md +│ │ ├── 02-implement-approved-plan.prompt.md +│ │ ├── 03-graph-api-consumer.prompt.md +│ │ ├── 04-graph-engine-integration.prompt.md +│ │ ├── 05-add-or-change-endpoint.prompt.md +│ │ ├── 06-docs-update.prompt.md +│ │ ├── 07-pr-summary.prompt.md +│ │ └── 08-post-change-verification.prompt.md +│ ├── instructions/ +│ │ ├── 00-operating-rules.instructions.md +│ │ ├── 01-ownership-boundaries.instructions.md +│ │ ├── 02-graph-api-first.instructions.md +│ │ ├── 03-graph-engine-single-source.instructions.md +│ │ ├── 04-errors-logging-secrets.instructions.md +│ │ ├── 05-k8s-minikube-scope.instructions.md +│ │ └── 06-external-service-resilience.instructions.md +│ └── skills/ +│ ├── graph-api-client/SKILL.md +│ ├── graph-engine-integration/SKILL.md +│ ├── k8s-deployment/SKILL.md +│ └── simulation-runner/SKILL.md +├── k8s/ +│ └── (removed - not needed) +├── test/ +│ └── simulation.test.js # Test file +└── docs/ + └── COPILOT-USAGE-GUIDE.md +``` + +--- + +## Code Style + +- **Naming:** camelCase for variables/functions, PascalCase for classes +- **Async:** Use async/await, not callbacks +- **Error handling:** Always wrap Graph Engine API calls in try-catch +- **Logging:** Never log secrets + +--- + +## Additional Context + +For detailed Copilot-specific rules, see: + +### Master Configuration +- `.github/copilot-instructions.md` — Single source of truth for Copilot behavior + +### Agent Personas (select from dropdown in Chat) +- `.github/agents/planner.agent.md` — Analyze, gather evidence, produce plans +- `.github/agents/implementer.agent.md` — Execute approved plans (requires `OK IMPLEMENT NOW`) +- `.github/agents/reviewer.agent.md` — Validate changes against rules +- `.github/agents/evidence-answerer.agent.md` — Answer questions with codebase proof (file+line+1–5 line snippet). No implementation. + +### Path-Specific Instructions (auto-applied) +- `.github/instructions/00-operating-rules.instructions.md` — Implementation lock, evidence requirements +- `.github/instructions/01-ownership-boundaries.instructions.md` — What this repo owns +- `.github/instructions/02-graph-api-first.instructions.md` — Graph Engine API is single source of truth +- `.github/instructions/04-errors-logging-secrets.instructions.md` — Security rules +- `.github/instructions/05-k8s-minikube-scope.instructions.md` — K8s context + +### Agent Skills (auto-loaded based on context) +- `.github/skills/graph-api-client/` — Graph Engine API consumption patterns +- `.github/skills/simulation-runner/` — Simulation logic patterns +- `.github/skills/k8s-deployment/` — Kubernetes deployment patterns + +### Reusable Prompts (invoke with `/` in chat) +- `.github/prompts/*.prompt.md` — 7 workflow templates + +> **Note:** Custom agents appear in the **agent dropdown** in Chat, not via `@` mentions. +- `.github/prompts/` — Reusable task prompts +- `.github/skills/` — Agent skills for specialized workflows diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..facb7a1 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,246 @@ +# Deployment Guide + +## ⚠️ Breaking Change (v2.0) + +The Kubernetes resources have been renamed from `predictive-analysis-engine` to `predictive-analysis-engine`. + +**Migration steps:** +1. Delete old resources: `kubectl delete deployment,svc predictive-analysis-engine` +2. Apply new manifests: `kubectl apply -k k8s/base/` +3. Update any clients/ingress referencing the old service name `predictive-analysis-engine` + +The service DNS name changes from `predictive-analysis-engine..svc.cluster.local` to `predictive-analysis-engine..svc.cluster.local`. + +--- + +## Local Demo (Current Phase) + +### Prerequisites + +- Node.js >= 18.x +- Running `service-graph-engine` instance (Graph Engine API) +- Graph Engine API URL configured + +### Setup + +```bash +# 1. Install dependencies +npm install + +# 2. Configure environment +cp .env.example .env + +# 3. Edit .env with Graph Engine API URL +# Required: SERVICE_GRAPH_ENGINE_URL +``` + +### Start Server + +```bash +npm start +``` + +**Expected output:** +``` +[2025-12-27T10:00:00.000Z] Predictive Analysis Engine started +Port: 5000 +Max traversal depth: 2 +Default latency metric: p95 +Scaling model: bounded_sqrt (alpha: 0.5) +Timeout: 8000ms +``` + +### Verify Connection + +```bash +curl http://localhost:5000/health +``` + +**Expected response:** +```json +{ + "status": "ok", + "dataSource": "graph-engine", + "provider": { + "connected": true, + "services": 11 + }, + "config": { + "maxTraversalDepth": 2, + "defaultLatencyMetric": "p95" + }, + "uptimeSeconds": 5.2 +} +``` + +--- + +## Demo Commands + +### 1. Health Check + +```bash +curl http://localhost:5000/health +``` + +### 2. Simulate Service Failure + +Simulates what happens if `checkoutservice` becomes unavailable. + +```bash +curl -X POST http://localhost:5000/simulate/failure \ + -H "Content-Type: application/json" \ + -d '{"serviceId": "default:checkoutservice"}' +``` + +**Expected response (abbreviated):** +```json +{ + "target": { + "serviceId": "default:checkoutservice", + "name": "checkoutservice", + "namespace": "default" + }, + "neighborhood": { + "serviceCount": 3, + "edgeCount": 2, + "depthUsed": 2 + }, + "affectedCallers": [ + { + "serviceId": "default:frontend", + "lostTrafficRps": 0.178, + "edgeErrorRate": 0.0 + } + ], + "criticalPathsToTarget": [ + { + "path": ["default:loadgenerator", "default:frontend", "default:checkoutservice"], + "pathRps": 0.178 + } + ], + "totalLostTrafficRps": 0.178 +} +``` + +### 3. Simulate Scaling + +Simulates scaling `frontend` from 2 to 6 pods and predicts latency impact. + +```bash +curl -X POST http://localhost:5000/simulate/scale \ + -H "Content-Type: application/json" \ + -d '{ + "serviceId": "default:frontend", + "currentPods": 2, + "newPods": 6 + }' +``` + +**Expected response (abbreviated):** +```json +{ + "target": { + "serviceId": "default:frontend", + "name": "frontend", + "namespace": "default" + }, + "latencyMetric": "p95", + "currentPods": 2, + "newPods": 6, + "latencyEstimate": { + "baselineMs": 34.67, + "projectedMs": 24.89, + "deltaMs": -9.78 + }, + "affectedCallers": { + "items": [ + { + "serviceId": "default:loadgenerator", + "hopDistance": 1, + "baselineMs": 34.67, + "projectedMs": 24.89, + "deltaMs": -9.78 + } + ] + } +} +``` + +--- + +## Troubleshooting + +### "Missing required environment variables" + +``` +❌ Missing required environment variables: + - SERVICE_GRAPH_ENGINE_URL is required +``` + +**Solution:** Ensure `.env` file exists with valid Graph Engine API URL. + +### "Service not found" + +**Cause:** Target service doesn't exist in Graph Engine. + +**Solution:** Verify `service-graph-engine` is running and has synced data: +```bash +curl http://localhost:8080/health +``` + +### "Query timeout exceeded" + +**Solution:** Reduce `maxDepth` in request or increase `TIMEOUT_MS` in `.env`. + +--- + +## Kubernetes Deployment (Future Phase) + +Kubernetes manifests are provided in `k8s/base/` for future in-cluster deployment. + +### Build Container Image + +```bash +docker build -t predictive-analysis-engine:latest . +``` + +### Load Image into Minikube + +The cluster cannot pull `predictive-analysis-engine:latest` from a registry—you must load it: + +**Option A (recommended):** +```bash +minikube image load predictive-analysis-engine:latest +``` + +**Option B (build inside minikube's Docker):** +```bash +eval $(minikube docker-env) +docker build -t predictive-analysis-engine:latest . +``` + +### Deploy to Cluster + +```bash +# Create config (example - or use ConfigMap) +kubectl set env deployment/predictive-analysis-engine \ + SERVICE_GRAPH_ENGINE_URL='http://service-graph-engine:8080' + +# Apply manifests +kubectl apply -k k8s/base/ + +# Port-forward for local access (use 7001 to avoid host conflicts) +kubectl port-forward svc/predictive-analysis-engine 7001:5000 +``` + +Then test via `http://localhost:7001/health`. + +### Resource Configuration + +| Resource | Request | Limit | +|----------|---------|-------| +| CPU | 100m | 300m | +| Memory | 128Mi | 256Mi | + +These are conservative defaults suitable for demo workloads. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9f7d025 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Build stage +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev + +# Production stage +FROM node:20-alpine +WORKDIR /app + +# Create non-root user +RUN addgroup -g 1001 appgroup && \ + adduser -u 1001 -G appgroup -s /bin/sh -D appuser + +# Copy dependencies from builder +COPY --from=builder /app/node_modules ./node_modules + +# Copy application code +COPY src/ ./src/ +COPY index.js ./ +COPY package.json ./ + +# Set ownership +RUN chown -R appuser:appgroup /app + +# Switch to non-root user +USER appuser + +# Expose port (default 5000, configurable via PORT env) +EXPOSE 5000 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:${PORT:-5000}/health || exit 1 + +# Start server +CMD ["node", "index.js"] diff --git a/README.md b/README.md index 0c5e802..16458ed 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,151 @@ -# What-If Simulation Engine +# Predictive Analysis Engine ## Overview -The What-If Simulation Engine is a microservice observability tool that performs predictive impact analysis on service call graphs. It enables operators to simulate infrastructure changes—service failures and scaling operations—before executing them in production, thereby reducing risk and improving operational decision-making. +The Predictive Analysis Engine is a microservice observability tool that performs predictive impact analysis on service call graphs. It enables operators to simulate infrastructure changes—service failures and scaling operations—before executing them in production, thereby reducing risk and improving operational decision-making. -This service integrates with the existing Neo4j-based service graph infrastructure (populated by `service-graph-engine`) to provide real-time "what-if" analysis capabilities. +**Source of Truth:** This service uses the Graph Engine API as its single data source. All graph topology and metrics data is retrieved via HTTP from `service-graph-engine`. + +## Quick Start (Local Development) + +### Prerequisites + +- **Node.js** v18+ (for running this service) +- **Neo4j Desktop** (running locally on macOS) +- **Minikube** (for microservice testbed) +- **kubectl** (Kubernetes CLI) +- **service-graph-engine** (running on port 3000) + +### Setup Steps + +1. **Start Minikube cluster with testbed:** + ```bash + # From repository root + chmod +x setup-local.sh + ./setup-local.sh + ``` + +2. **Port-forward Prometheus (REQUIRED - keep running):** + ```bash + kubectl port-forward svc/prometheus -n istio-system 9090:9090 + ``` + +3. **Configure and start service-graph-engine:** + ```bash + cd ../service-graph-engine + cp .env.example .env + # Edit .env: + # NEO4J_URI=bolt://localhost:7687 + # NEO4J_PASSWORD=your-actual-password + # NEO4J_DATABASE=neo4j + # PROMETHEUS_URL=http://localhost:9090 + npm install + npm start + ``` + +4. **Configure this service:** + ```bash + cd predictive-analysis-engine + cp .env.example .env + # Edit .env: + # SERVICE_GRAPH_ENGINE_URL=http://localhost:3000 + npm install + ``` + +5. **Start this service:** + ```bash + npm start + ``` + +6. **Generate traffic (so data flows into the system):** + ```bash + # Port-forward frontend (new terminal) + kubectl port-forward svc/frontend 8080:80 + + # Access frontend to generate traffic + open http://localhost:8080 + # Or use curl: + for i in {1..10}; do curl -s http://localhost:8080 > /dev/null; sleep 2; done + ``` + +7. **Wait 1-2 minutes** for data collection, then test: + ```bash + curl http://localhost:5000/health + open http://localhost:5000/swagger + ``` + +### Required Running Terminals + +``` +Terminal 1: kubectl port-forward svc/prometheus -n istio-system 9090:9090 ← REQUIRED +Terminal 2: service-graph-engine (npm start) ← REQUIRED +Terminal 3: predictive-analysis-engine (npm start) ← REQUIRED +Terminal 4: kubectl port-forward svc/frontend 8080:80 ← For traffic generation +``` ## Architecture ### System Context ``` -┌─────────────────────┐ -│ Prometheus │ -│ (Metrics Source) │ -└──────────┬──────────┘ - │ - ▼ -┌─────────────────────┐ ┌──────────────────┐ -│ service-graph- │──────▶│ Neo4j │ -│ engine │ │ (Graph Database) │ -│ (Metric Ingestion) │ └────────┬─────────┘ -└─────────────────────┘ │ - │ READ-ONLY - ▼ - ┌──────────────────────┐ - │ what-if-simulation- │ - │ engine │ - │ (This Service) │ - └──────────┬───────────┘ - │ - ▼ - ┌──────────────────────┐ - │ REST API Consumers │ - │ (Operators, UIs) │ - └──────────────────────┘ +┌─────────────────────────────────────────────────────────────────┐ +│ Minikube (3 nodes) │ +│ │ +│ ┌────────────────────────────────┐ ┌─────────────────────┐ │ +│ │ Microservice Testbed │ │ Prometheus │ │ +│ │ (11 services with Istio) │──▶│ (Istio Metrics) │ │ +│ └────────────────────────────────┘ └─────────────────────┘ │ +│ │ port-forward │ +│ │ :9090 │ +└─────────────────────────────────────────────┼───────────────────┘ + │ + ┌─────────────────────────────┘ + │ +┌───────────────┼────────────────── macOS ─────────────────────┐ +│ ▼ │ +│ ┌──────────────────────┐ ┌──────────────────────┐ │ +│ │ service-graph- │ │ predictive- │ │ +│ │ engine │◄───────│ analysis-engine │ │ +│ │ (Node.js :3000) │ │ (Node.js :5000) │ │ +│ └──────────────────────┘ └──────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ Neo4j │ │ +│ │ (localhost:7687) │ │ +│ └──────────────────────┘ │ +└───────────────────────────────────────────────────────────────┘ ``` ### Key Design Principles -1. **Read-Only Analysis**: All Neo4j queries are read-only. Graph modifications exist only in-memory during simulation execution. +1. **Graph Engine Only**: This service exclusively uses the Graph Engine HTTP API. No direct database access. Graph modifications exist only in-memory during simulation execution. 2. **Configurable Defaults**: All simulation parameters (latency metrics, scaling formulas, traversal depth) are configurable via environment variables or per-request overrides. 3. **Performance Bounded**: Hard limits on traversal depth (max 3 hops) and path enumeration (top N=10) prevent combinatorial explosion on large graphs. -4. **Timeout Enforcement**: Two-layer timeout protection (Neo4j transaction timeout + overall request timeout) ensures fast failure detection. +4. **Timeout Enforcement**: HTTP request timeouts ensure fast failure detection when Graph Engine is unavailable. + +## Data Model -## Graph Schema +The engine consumes graph data from the Graph Engine API with the following structure: -The engine operates on the following Neo4j schema (managed by `service-graph-engine`): +**Service Nodes:** +- `serviceId` / `name`: Service identifier (plain name like "frontend") +- `namespace`: Service namespace (typically "default") -**Nodes:** -- Label: `Service` -- Properties: `serviceId` (unique), `name`, `namespace`, `createdAt`, `updatedAt`, `pagerank`, `betweenness` +**Edges (Calls):** +- `from` → `to`: Caller → callee direction +- `rate`: Request rate (RPS from Prometheus metrics) +- `errorRate`: Error rate (RPS) +- `p50`, `p95`, `p99`: Latency percentiles (milliseconds) -**Relationships:** -- Type: `CALLS_NOW` (direction: caller → callee) -- Properties: `rate`, `errorRate`, `p50`, `p95`, `p99`, `windowStart`, `windowEnd`, `lastUpdated` +> **Note:** The Graph Engine API provides plain service names (e.g., "frontend") rather than namespace-prefixed identifiers. This service handles both formats for backward compatibility. -**ServiceId Format:** `"namespace:name"` (e.g., `"default:frontend"`) +**Data Freshness:** +- Graph Engine provides staleness indicators +- Simulations abort if data is stale ## Configuration @@ -67,23 +153,25 @@ All configuration is managed via environment variables with sensible defaults. | Variable | Default | Description | |----------|---------|-------------| -| `NEO4J_URI` | `neo4j+s://...` | Neo4j connection URI | -| `NEO4J_USER` | `neo4j` | Neo4j username | -| `NEO4J_PASSWORD` | *(required)* | Neo4j password (never logged) | +| `SERVICE_GRAPH_ENGINE_URL` | `http://service-graph-engine:3000` | Graph Engine API base URL | +| `GRAPH_ENGINE_BASE_URL` | *(alias)* | Alternative name for SERVICE_GRAPH_ENGINE_URL | +| `GRAPH_API_TIMEOUT_MS` | `5000` | Graph Engine HTTP request timeout (ms) | | `DEFAULT_LATENCY_METRIC` | `p95` | Default latency metric (p50, p95, p99) | | `MAX_TRAVERSAL_DEPTH` | `2` | Maximum k-hop traversal depth (1-3) | | `SCALING_MODEL` | `bounded_sqrt` | Scaling formula (bounded_sqrt, linear) | | `SCALING_ALPHA` | `0.5` | Fixed overhead fraction (0.0-1.0) | | `MIN_LATENCY_FACTOR` | `0.6` | Minimum latency improvement factor | -| `TIMEOUT_MS` | `8000` | Query and request timeout (ms) | +| `TIMEOUT_MS` | `8000` | Overall request timeout (ms) | | `MAX_PATHS_RETURNED` | `10` | Maximum paths in simulation results | -| `PORT` | `3000` | HTTP server port | +| `PORT` | `5000` | HTTP server port | +| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate limit sliding window (ms) | +| `RATE_LIMIT_MAX_REQUESTS` | `60` | Max requests per window per client | **Setup:** ```bash cp .env.example .env -# Edit .env with your Neo4j credentials +# Edit .env with your Graph Engine URL ``` ## API Reference @@ -96,17 +184,23 @@ cp .env.example .env ```json { "status": "ok", - "neo4j": { + "provider": "graph-engine", + "graphApi": { "connected": true, - "services": 11 + "status": "healthy", + "stale": false, + "lastUpdatedSecondsAgo": 12 }, - "uptime": 42.3 + "config": { + "maxTraversalDepth": 2, + "defaultLatencyMetric": "p95" + }, + "uptimeSeconds": 42.3 } ``` **Status Codes:** -- `200 OK`: Service healthy -- `500 Internal Server Error`: Service error +- `200 OK`: Always (even when degraded) --- @@ -155,28 +249,39 @@ Or, using name/namespace: "name": "checkoutservice", "namespace": "default" }, - "depth": 2, + "neighborhood": { + "description": "k-hop upstream subgraph around target (not full graph)", + "serviceCount": 3, + "edgeCount": 2, + "depthUsed": 2, + "generatedAt": "2025-12-25T08:22:28.950Z" + }, "affectedCallers": [ { "serviceId": "default:frontend", + "name": "frontend", + "namespace": "default", "lostTrafficRps": 0.178, "edgeErrorRate": 0.0 } ], - "criticalPathsBroken": [ + "criticalPathsToTarget": [ { "path": ["default:loadgenerator", "default:frontend", "default:checkoutservice"], "pathRps": 0.178 } - ] + ], + "totalLostTrafficRps": 0.178 } ``` **Response Fields:** +- `neighborhood`: Metadata about the k-hop upstream subgraph used for analysis - `affectedCallers`: Direct callers that lose traffic, sorted by `lostTrafficRps` descending -- `criticalPathsBroken`: Top N paths by traffic volume that include the failed service +- `criticalPathsToTarget`: Top N paths by traffic volume that include the failed service - `pathRps`: Bottleneck throughput (min edge rate along path) +- `totalLostTrafficRps`: Sum of lost traffic across all affected callers **Status Codes:** - `200 OK`: Simulation successful @@ -188,7 +293,7 @@ Or, using name/namespace: **Example:** ```bash -curl -X POST http://localhost:3000/simulate/failure \ +curl -X POST http://localhost:5000/simulate/failure \ -H "Content-Type: application/json" \ -d '{"serviceId": "default:checkoutservice"}' ``` @@ -225,7 +330,7 @@ Simulates changing the pod count for a service and computes the impact on latenc | `name` | string | conditional* | Service name | | `namespace` | string | conditional* | Service namespace | | `currentPods` | number | **required** | Current pod count (positive integer) | -| `newPods` | number | **required** | New pod count (positive integer) | +| `newPods` | number | **required** | New pod count (positive integer). Aliases: `targetPods`, `pods` | | `latencyMetric` | string | optional | Latency metric (p50, p95, p99, default: p95) | | `model.type` | string | optional | Scaling model (bounded_sqrt, linear, default: bounded_sqrt) | | `model.alpha` | number | optional | Fixed overhead fraction (0.0-1.0, default: 0.5) | @@ -233,6 +338,8 @@ Simulates changing the pod count for a service and computes the impact on latenc *Either `serviceId` OR (`name` AND `namespace`) required. +> **Parameter Aliases:** For convenience, `newPods` accepts aliases `targetPods` and `pods`. If multiple aliases are provided with conflicting values, the request returns 400. + **Response:** ```json @@ -242,22 +349,39 @@ Simulates changing the pod count for a service and computes the impact on latenc "name": "frontend", "namespace": "default" }, - "latencyMetric": "p95", + "scalingModel": { + "type": "bounded_sqrt", + "alpha": 0.5 + }, + "neighborhood": { + "description": "k-hop upstream subgraph around target (not full graph)", + "serviceCount": 2, + "edgeCount": 1, + "depthUsed": 2, + "generatedAt": "2025-12-25T08:22:28.950Z" + }, + "latencyEstimate": { + "description": "Latency figures: baselineMs is current weighted mean, projectedMs is post-scaling estimate, unit is milliseconds", + "metric": "p95" + }, "currentPods": 2, "newPods": 6, "affectedCallers": [ { "serviceId": "default:loadgenerator", - "beforeMs": 34.67, - "afterMs": 24.89, + "name": "loadgenerator", + "namespace": "default", + "hopDistance": 1, + "baselineMs": 34.67, + "projectedMs": 24.89, "deltaMs": -9.78 } ], "affectedPaths": [ { "path": ["default:loadgenerator", "default:frontend"], - "beforeMs": 34.67, - "afterMs": 24.89, + "baselineMs": 34.67, + "projectedMs": 24.89, "deltaMs": -9.78 } ] @@ -266,8 +390,11 @@ Simulates changing the pod count for a service and computes the impact on latenc **Response Fields:** -- `affectedCallers`: Callers with changed latency, sorted by absolute `deltaMs` descending -- `beforeMs`, `afterMs`: Weighted mean latency (may be `null` if caller has zero traffic) +- `neighborhood`: Metadata about the k-hop upstream subgraph used for analysis +- `latencyEstimate`: Description and metric for latency values (all in milliseconds) +- `affectedCallers`: ALL upstream nodes in neighborhood with latency impact, sorted by `|deltaMs|` descending +- `hopDistance`: Minimum hop distance from caller to target (1 = direct, 2 = 2-hop, etc.) +- `baselineMs`, `projectedMs`: Weighted mean latency before/after scaling (may be `null` if no traffic) - `deltaMs`: Latency change (negative = improvement) - `affectedPaths`: Top N paths by traffic with latency changes @@ -281,7 +408,7 @@ Simulates changing the pod count for a service and computes the impact on latenc **Example:** ```bash -curl -X POST http://localhost:3000/simulate/scale \ +curl -X POST http://localhost:5000/simulate/scale \ -H "Content-Type: application/json" \ -d '{ "serviceId": "default:frontend", @@ -292,6 +419,206 @@ curl -X POST http://localhost:3000/simulate/scale \ --- +### Risk Analysis + +**Endpoint:** `GET /risk/services/top` + +Returns the top services by centrality-based risk score. Services with higher centrality (PageRank or betweenness) are at higher risk of causing cascading failures. + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `metric` | string | `pagerank` | Centrality metric (`pagerank` or `betweenness`) | +| `limit` | number | `5` | Number of services to return (1-20) | + +**Response:** + +```json +{ + "metric": "pagerank", + "services": [ + { + "serviceId": "default:frontend", + "name": "frontend", + "score": 0.2847, + "riskLevel": "high", + "explanation": "frontend has high PageRank (0.2847), indicating it is a critical hub. Failure could cascade widely." + }, + { + "serviceId": "default:checkoutservice", + "name": "checkoutservice", + "score": 0.1523, + "riskLevel": "medium", + "explanation": "checkoutservice has moderate PageRank (0.1523). Monitor for dependencies." + } + ], + "generatedAt": "2025-12-29T10:00:00.000Z" +} +``` + +**Response Fields:** + +- `metric`: Centrality metric used for ranking +- `services`: Top N services by centrality score, each with: + - `riskLevel`: `high` (top 20%), `medium` (20-50%), or `low` (bottom 50%) + - `explanation`: Human-readable risk explanation +- `generatedAt`: Timestamp of analysis + +**Example:** + +```bash +curl "http://localhost:5000/risk/services/top?metric=pagerank&limit=10" +``` + +--- + +### Recommendations in Simulation Responses + +Both failure and scaling simulation responses now include actionable recommendations: + +**Failure Simulation Response (new field):** +```json +{ + "target": { ... }, + "affectedCallers": [ ... ], + "recommendations": [ + { + "type": "circuit-breaker", + "priority": "high", + "message": "Consider implementing circuit breakers for callers losing >50 RPS." + }, + { + "type": "redundancy", + "priority": "medium", + "message": "3 callers depend on this service. Consider deploying replicas or fallback endpoints." + } + ] +} +``` + +**Scaling Simulation Response (new field):** +```json +{ + "target": { ... }, + "latencyEstimate": { ... }, + "recommendations": [ + { + "type": "scaling-benefit", + "priority": "medium", + "message": "Scaling from 2 to 4 pods shows >30% latency improvement. Proceed if cost-effective." + } + ] +} +``` + +**Recommendation Types:** + +| Type | Applies To | Description | +|------|-----------|-------------| +| `data-quality-warning` | Both | Low confidence due to stale/missing data | +| `circuit-breaker` | Failure | High traffic loss suggests circuit breakers | +| `redundancy` | Failure | Multiple callers suggest replication | +| `topology-review` | Failure | Unreachable services detected | +| `monitoring` | Failure | Low impact, but monitor affected callers | +| `scaling-caution` | Scale | Scaling down increases latency significantly | +| `scaling-benefit` | Scale | Scaling up provides >30% improvement | +| `cost-efficiency` | Scale | Minimal benefit, may not justify cost | +| `propagation-awareness` | Scale | Callers will see latency changes | +| `proceed` | Scale | No significant impact detected | + +--- + +## Operational Features + +### Correlation ID + +All requests are assigned a unique correlation ID for distributed tracing: + +- **Header:** `X-Correlation-Id` +- If provided in the request, it is preserved; otherwise, a UUID is generated +- All log entries include the correlation ID for request tracing + +**Example:** +```bash +curl -H "X-Correlation-Id: my-trace-123" http://localhost:5000/health +# Response includes: X-Correlation-Id: my-trace-123 +``` + +### Rate Limiting + +Simulation endpoints (`POST /simulate/*`) are rate-limited to prevent abuse: + +- **Default:** 60 requests per minute per client IP +- **Headers returned:** + - `X-RateLimit-Limit`: Maximum requests per window + - `X-RateLimit-Remaining`: Remaining requests in current window + - `X-RateLimit-Reset`: Unix timestamp when window resets + +**Rate Limit Exceeded (HTTP 429):** +```json +{ + "error": "Too many requests", + "retryAfterMs": 45000 +} +``` + +### Structured Logging + +All logs are output in JSON format for easy parsing: + +```json +{ + "timestamp": "2025-12-29T10:00:00.000Z", + "level": "info", + "message": "request_start", + "correlationId": "abc-123", + "method": "POST", + "path": "/simulate/failure" +} +``` + +--- + +## Evaluation Harness + +CLI tools for evaluating simulation accuracy against ground truth: + +### Run Scenarios + +```bash +node tools/eval/run.js \ + --scenarios tools/eval/scenarios.sample.json \ + --output predictions.json \ + --base-url http://localhost:5000 +``` + +**Scenario Format:** +```json +[ + { + "id": "scenario-1", + "type": "failure", + "request": { "serviceId": "default:frontend" } + } +] +``` + +### Score Predictions + +```bash +node tools/eval/score.js \ + --predictions predictions.json \ + --ground-truth tools/eval/groundTruth.sample.json +``` + +**Metrics Computed:** +- **MAE** (Mean Absolute Error) +- **MAPE** (Mean Absolute Percentage Error) +- **Spearman ρ** (rank correlation, if N ≥ 2) + +--- + ## Simulation Algorithms ### Failure Simulation @@ -381,7 +708,7 @@ newLatency = baseLatency * (currentPods / newPods) **Request:** ```bash -curl -X POST http://localhost:3000/simulate/failure \ +curl -X POST http://localhost:5000/simulate/failure \ -H "Content-Type: application/json" \ -d '{ "serviceId": "default:checkoutservice", @@ -429,7 +756,7 @@ curl -X POST http://localhost:3000/simulate/failure \ **Request:** ```bash -curl -X POST http://localhost:3000/simulate/scale \ +curl -X POST http://localhost:5000/simulate/scale \ -H "Content-Type: application/json" \ -d '{ "serviceId": "default:frontend", @@ -481,8 +808,8 @@ curl -X POST http://localhost:3000/simulate/scale \ ### Prerequisites -- Node.js >= 14.x -- Neo4j database (populated by `service-graph-engine`) +- Node.js >= 18.x +- Access to `service-graph-engine` HTTP API ### Installation @@ -494,7 +821,7 @@ npm install ```bash cp .env.example .env -# Edit .env with your Neo4j credentials +# Edit .env with your Graph Engine URL (default: http://service-graph-engine:3000) ``` ### Start Server @@ -506,8 +833,8 @@ npm start **Output:** ``` -[2025-12-25T10:00:00.000Z] What-if Simulation Engine started -Port: 3000 +[2025-12-25T10:00:00.000Z] Predictive Analysis Engine started +Port: 5000 Max traversal depth: 2 Default latency metric: p95 Scaling model: bounded_sqrt (alpha: 0.5) @@ -517,7 +844,7 @@ Timeout: 8000ms ### Verify Deployment ```bash -curl http://localhost:3000/health +curl http://localhost:5000/health ``` **Expected Response:** @@ -525,11 +852,12 @@ curl http://localhost:3000/health ```json { "status": "ok", - "neo4j": { + "provider": "graph-engine", + "graphApi": { "connected": true, - "services": 11 + "status": "healthy" }, - "uptime": 5.2 + "uptimeSeconds": 5.2 } ``` @@ -547,10 +875,10 @@ npm test ## Security Considerations -1. **Credential Management**: Neo4j password is never logged (redacted in all error messages) -2. **Read-Only Access**: All Neo4j queries use `READ` access mode -3. **Input Validation**: All user inputs validated before use -4. **Timeout Protection**: Prevents resource exhaustion from expensive queries +1. **HTTP Only**: All data access via Graph Engine HTTP API (no direct database access) +2. **Input Validation**: All user inputs validated before use +3. **Timeout Protection**: Prevents resource exhaustion from expensive Graph Engine queries +4. **Rate Limiting**: Simulation endpoints protected against abuse --- @@ -576,14 +904,16 @@ npm test ### With service-graph-engine -- **Dependency**: Reads same Neo4j graph (Services + CALLS_NOW edges) -- **Schema**: Assumes schema managed by `service-graph-engine` -- **No coordination required**: Both services are read-only consumers +- **Dependency**: Consumes Graph Engine HTTP API for topology and metrics +- **Endpoints Used**: + - `GET /graph/health` - Data freshness status + - `GET /services/{name}/neighborhood?k={depth}` - k-hop neighborhood +- **No coordination required**: Graph Engine provides read-only data access ### With Other Components -- **REST API**: Standard HTTP JSON (no authentication in Progress 1) -- **Service Identifier**: Accepts both `serviceId` and `name`+`namespace` formats +- **REST API**: Standard HTTP JSON (no authentication in current version) +- **Service Identifier**: Accepts plain service names (e.g., "frontend") - **Extensible**: Response format includes detailed metadata for downstream processing --- @@ -592,12 +922,12 @@ npm test ### Error: "Service not found" -**Cause:** Target service does not exist in Neo4j graph +**Cause:** Target service does not exist in Graph Engine **Solution:** Verify service exists: ```bash -curl -X POST http://localhost:3000/simulate/failure \ +curl -X POST http://localhost:5000/simulate/failure \ -H "Content-Type: application/json" \ -d '{"serviceId": "default:frontend"}' ``` @@ -613,19 +943,42 @@ Check `/health` endpoint for service count. **Solution:** 1. Reduce `maxDepth` in request (try 1 instead of 2) 2. Increase `TIMEOUT_MS` in `.env` (if graph is legitimately large) +3. Check Graph Engine performance --- -### Error: "Neo4j connection failed" +### Error: "Graph API unavailable" -**Cause:** Invalid credentials or unreachable database +**Cause:** Cannot reach Graph Engine or it returned an error -**Solution:** Verify Neo4j credentials in `.env`: +**Solution:** +1. Verify Graph Engine is running: `curl http://service-graph-engine:3000/health` +2. Check `SERVICE_GRAPH_ENGINE_URL` in `.env` +3. Review Graph Engine logs -```bash -# Test connection -node verify-schema.js -``` +--- + +## Copilot Integration + +This repository includes extensive GitHub Copilot customization for AI-assisted development: + +| Component | Location | Purpose | +|-----------|----------|---------| +| **Custom Agents** | `.github/agents/` | Planner, Implementer, Reviewer personas | +| **Instruction Files** | `.github/instructions/` | Path-specific coding rules (6 files) | +| **Agent Skills** | `.github/skills/` | Specialized knowledge modules (4 skills) | +| **Prompt Templates** | `.github/prompts/` | Reusable workflow prompts (7 files) | + +**Key workflow:** +1. Select **Planner** from agent dropdown → Describe your task +2. Review the plan, ask questions +3. Type `OK IMPLEMENT NOW` to approve +4. Select **Implementer** → Execute the plan +5. Select **Reviewer** → Validate changes + +For complete documentation, see: +- [AGENTS.md](AGENTS.md) — Universal agent instructions +- [docs/COPILOT-USAGE-GUIDE.md](docs/COPILOT-USAGE-GUIDE.md) — Detailed usage guide --- diff --git a/docs/COPILOT-USAGE-GUIDE.md b/docs/COPILOT-USAGE-GUIDE.md new file mode 100644 index 0000000..14fbc93 --- /dev/null +++ b/docs/COPILOT-USAGE-GUIDE.md @@ -0,0 +1,305 @@ +# Copilot Usage Guide — predictive-analysis-engine + +This guide explains how to use the custom agents in this repository with VS Code Copilot Chat, including normal chat sessions and Background Agents (Copilot CLI). + +--- + +## Quick Reference + +| Agent | Purpose | Tools | How to Select | +|-------|---------|-------|---------------| +| **Planner** | Analyze, gather evidence, produce plans | `read`, `search` | Agent dropdown → Planner | +| **Implementer** | Execute approved plans | `read`, `edit`, `search`, + MCP tools (Firecrawl, Brave Search, Tavily, Context7, Git, etc.) | Agent dropdown → Implementer | +| **Reviewer** | Validate changes against rules | `read`, `search`, + MCP tools (Git, Firecrawl, Tavily, etc.) | Agent dropdown → Reviewer | + +> **Note:** Custom agents are selected from the **Agents dropdown** at the bottom of the Chat view, NOT via `@` mentions. The `@` syntax is reserved for built-in chat participants like `@workspace` and `@terminal`. + +**Approval phrase (required before any edits):** +``` +OK IMPLEMENT NOW +``` + +--- + +## 1. Using Agents in Normal Chat Sessions + +### Starting with the Planner + +1. Open VS Code Copilot Chat (`Ctrl+Alt+I` or `Cmd+Alt+I`) +2. Click the **agent picker dropdown** at the bottom of the chat input (shows "Agent", "Plan", "Ask", or "Edit" by default) +3. Select **Planner** from the list of custom agents +4. Describe what you want to accomplish: + +``` +I want to add a new endpoint POST /simulate/cascade that analyzes cascade failure scenarios. +``` + +The Planner will: +- Gather evidence from the codebase +- Produce a structured plan +- Ask clarifying questions +- Wait for your approval + +### Approving Implementation + +When you're satisfied with the plan, type exactly: + +``` +OK IMPLEMENT NOW +``` + +Then click the **Start Implementation** handoff button to switch to the Implementer agent. + +### Reviewing Changes + +After implementation, click **Review My Changes** to switch to the Reviewer agent. + +The Reviewer will: +- Check plan compliance +- Check for security/logging issues +- Provide a structured report + +### Workflow Diagram + +``` +┌──────────┐ OK IMPLEMENT NOW ┌─────────────┐ Review ┌──────────┐ +│ Planner │ ───────────────────▶ │ Implementer │ ──────────▶ │ Reviewer │ +│ │ │ │ │ │ +│ • Read │ │ • Read │ │ • Read │ +│ • Search │ │ • Search │ │ • Search │ +│ │ │ • Edit │ │ │ +└──────────┘ └─────────────┘ └──────────┘ + ▲ │ + │ Re-plan (if needed) │ + └─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Using Agents as Background Agents (Copilot CLI) + +Background Agents run autonomously via the Copilot CLI while you continue other work. They're ideal for well-scoped tasks after planning is complete. + +### Prerequisites + +1. Install Copilot CLI: + ```bash + npm install -g @github/copilot + ``` + +2. Enable custom agents for background sessions in VS Code settings: + ```json + { + "github.copilot.chat.cli.customAgents.enabled": true, + "chat.agent.enabled": true, + "chat.useAgentsMdFile": true, + "chat.useAgentSkills": true + } + ``` + +3. (Optional) Enable organization-level agents: + ```json + { + "github.copilot.chat.customAgents.showOrganizationAndEnterpriseAgents": true + } + ``` + +### Starting a Background Agent Session + +**Option A: From VS Code** +1. Open Chat view (`Ctrl+Alt+I`) +2. Select **New Chat** dropdown → **New Background Agent** +3. Select a custom agent (e.g., `Planner`, `Implementer`) +4. Enter your task description + +**Option B: Hand off from local chat** +1. Complete planning with the Planner agent +2. Get approval (`OK IMPLEMENT NOW`) +3. Select **Continue In** → **Background Agent** + +**Option C: Use `@cli` in chat** +``` +@cli Implement the approved plan for adding POST /simulate/cascade +``` + +### Background Agent Limitations + +⚠️ **Important:** Background agents have different capabilities than local agents: + +| Feature | Local Agent | Background Agent | +|---------|-------------|------------------| +| VS Code runtime context | ✅ | ❌ | +| Failed test information | ✅ | ❌ | +| Text selections | ✅ | ❌ | +| MCP servers | ✅ | ❌ | +| Extension-provided tools | ✅ | ❌ | +| Terminal commands | ✅ | ✅ (may prompt) | +| File read/edit | ✅ | ✅ | + +### Worktree Isolation (Recommended) + +To prevent conflicts with your active work: + +1. Start a background agent session +2. Select **Worktree** for isolation mode +3. The agent works in a separate Git worktree +4. Review and merge changes when complete + +--- + +## 3. Safety Guidelines + +### Always Review Diffs + +Before accepting any changes: +- Use Source Control view to review all modified files +- Check for unintended scope creep +- Verify API contracts are correct + +### Never Put Secrets in Prompts + +❌ **Don't:** +``` +Connect to database using password "mySecretPassword123" +``` + +✅ **Do:** +``` +Use environment variables for authentication +``` + +--- + +## 4. Common Workflows + +### Adding a New Endpoint + +1. Select **Planner** from agent dropdown — Describe the endpoint +2. Review plan, ask questions +3. `OK IMPLEMENT NOW` +4. Click **Start Implementation** +5. Click **Review My Changes** +6. Manually test: `npm start` + call endpoint + +### Consuming Graph Engine API + +1. Select **Planner** from agent dropdown — Describe data needed +2. Provide Graph Engine API contract if known +3. Plan should use Graph Engine API exclusively +4. `OK IMPLEMENT NOW` +5. Verify `SERVICE_GRAPH_ENGINE_URL` usage in implementation + +--- + +## 5. Prompt Files + +Reusable prompts are in `.github/prompts/`: + +| Prompt | Purpose | +|--------|---------| +| `01-plan-change.prompt.md` | Template for planning changes | +| `02-implement-approved-plan.prompt.md` | Template for triggering implementation | +| `03-graph-api-consumer.prompt.md` | Consuming Graph Engine API | +--- + +## 6. Troubleshooting + +### Agent Not Appearing in Dropdown + +1. Ensure files are in `.github/agents/` with `.agent.md` extension +2. Verify VS Code settings are enabled: + ```json + { + "chat.agent.enabled": true, + "chat.useAgentsMdFile": true + } + ``` +3. Reload VS Code window (`Ctrl+Shift+P` → "Developer: Reload Window") +4. Check for YAML frontmatter syntax errors in agent files + +### Why Don't Agents Show with @ Autocomplete? + +Custom agents (`.github/agents/*.agent.md`) are designed to appear in the **agent dropdown**, NOT via `@` mentions. + +- **Dropdown agents** = Custom agents defined in `.github/agents/` +- **@ participants** = Built-in VS Code participants (`@workspace`, `@terminal`, `@vscode`) or extension-contributed participants + +This is expected behavior, not a bug. + +### Background Agent Can't Use Custom Agent + +Verify setting is enabled: +```json +"github.copilot.chat.cli.customAgents.enabled": true +``` + +### Implementer Refuses to Edit + +The Implementer requires the exact phrase `OK IMPLEMENT NOW` in the current conversation. Check: +- Phrase is spelled exactly (case-sensitive) +- Phrase was sent in the current session (not a previous one) + +--- + +## 7. Agent Skills + +Agent Skills are specialized knowledge modules that Copilot automatically loads when relevant to your prompt. They're stored in `.github/skills/`. + +| Skill | Purpose | When Loaded | +|-------|---------|-------------| +| **graph-api-client** | Guide for consuming the Graph Engine API service | When asked to fetch graph data or integrate with Graph Engine | +| **simulation-runner** | Guide for running and extending simulation logic | When asked about failure/scaling simulations | +| **k8s-deployment** | Guide for Kubernetes deployment patterns | When asked about K8s manifests or deployment | + +Skills are loaded automatically based on context. You don't need to reference them explicitly. + +--- + +## 8. Instruction Files + +Path-specific instructions in `.github/instructions/` are automatically applied based on which files you're working with: + +| File | Applies To | Purpose | +|------|------------|---------| +| `00-operating-rules.instructions.md` | `**/*` | Absolute rules: implementation lock, evidence requirements | +| `01-ownership-boundaries.instructions.md` | `**/*` | What this repo owns vs external teams | +| `02-graph-api-first.instructions.md` | `**/graphEngineClient.js`, `**/providers/**/*.js` | Graph Engine API is single source of truth | +| `04-errors-logging-secrets.instructions.md` | `**/*.js` | Never log credentials | +## 9. Required VS Code Settings + +Ensure these settings are enabled in `.vscode/settings.json`: + +```json +{ + // Enable custom agents from .github/agents/ + "chat.agent.enabled": true, + "chat.useAgentsMdFile": true, + + // Enable agent skills from .github/skills/ + "chat.useAgentSkills": true, + + // Enable instruction files from .github/instructions/ + "github.copilot.chat.codeGeneration.useInstructionFiles": true, + "chat.instructionsFilesLocations": { + ".github/instructions": true + }, + + // Enable prompt files from .github/prompts/ + "chat.promptFilesLocations": { + ".github/prompts": true + }, + + // Enable custom agents in background/CLI sessions + "github.copilot.chat.cli.customAgents.enabled": true +} +``` + +--- + +## 10. Related Files + +- [.github/copilot-instructions.md](../.github/copilot-instructions.md) — Master instruction file +- [.github/instructions/](../.github/instructions/) — Path-specific coding standards (6 files) +- [.github/agents/](../.github/agents/) — Agent definitions (Planner, Implementer, Reviewer) +- [.github/prompts/](../.github/prompts/) — Reusable prompt templates (7 files) +- [.github/skills/](../.github/skills/) — Agent skills (4 folders) diff --git a/index.js b/index.js index 72cc730..eaf0553 100644 --- a/index.js +++ b/index.js @@ -1,48 +1,187 @@ const express = require('express'); -const config = require('./src/config'); -const { checkHealth, closeDriver } = require('./src/neo4j'); -const { simulateFailure } = require('./src/failureSimulation'); -const { simulateScaling } = require('./src/scalingSimulation'); +const config = require('./src/config/config'); +const { validateEnv } = require('./src/config/config'); +const { getProvider } = require('./src/storage/providers'); +const { checkGraphHealth, getServices, getServicesWithPlacement, getMetricsSnapshot } = require('./src/clients/graphEngineClient'); +const { simulateFailure } = require('./src/simulation/failureSimulation'); +const { simulateScaling } = require('./src/simulation/scalingSimulation'); +const { simulateAdd } = require('./src/simulation/addSimulation'); +const { getTopRiskServices } = require('./src/simulation/riskAnalysis'); +const { correlationMiddleware } = require('./src/middleware/correlation'); +const { rateLimitMiddleware } = require('./src/middleware/rateLimit'); +const { setupSwagger } = require('./src/utils/swagger'); +const { parseTraceOptions } = require('./src/utils/traceOptions'); +const { createTrace } = require('./src/utils/trace'); +const { getWorker } = require('./src/telemetry/pollWorker'); +const { getDecisionStore, closeDecisionStore } = require('./src/storage/decisionStoreSingleton'); const { parseServiceIdentifier, + normalizePodParams, validateScalingParams, validateLatencyMetric, validateDepth, validateScalingModel -} = require('./src/validator'); +} = require('./src/utils/validator'); +const dependencyGraphRouter = require('./src/routes/dependencyGraph'); + +// Validate environment before starting server +validateEnv(); const app = express(); app.use(express.json()); +// Swagger UI (conditional - only if ENABLE_SWAGGER=true) +setupSwagger(app); + +// Correlation ID middleware (generates UUID, sets X-Correlation-Id header, logs requests) +app.use(correlationMiddleware()); + +// Mount dependency graph routes +app.use('/api/dependency-graph', dependencyGraphRouter); + // Track server start time const startTime = Date.now(); /** * Health check endpoint - * Returns Neo4j connectivity status and service count + * Returns Graph Engine connectivity status and config info + * Always returns HTTP 200 with status: "ok" | "degraded" */ app.get('/health', async (req, res) => { try { - const health = await checkHealth(); - const uptime = (Date.now() - startTime) / 1000; - + const uptimeSeconds = Math.round((Date.now() - startTime) / 100) / 10; + + // Check Graph Engine health + const graphResult = await checkGraphHealth(); + + let status = 'ok'; + let graphApi; + + if (graphResult.ok) { + const { stale, lastUpdatedSecondsAgo } = graphResult.data; + + // Status is degraded if graph is stale + if (stale) { + status = 'degraded'; + } + + graphApi = { + connected: true, + status: graphResult.data.status, + stale, + lastUpdatedSecondsAgo, + baseUrl: config.graphApi.baseUrl, + timeoutMs: config.graphApi.timeoutMs + }; + } else { + // Graph Engine unavailable = always degraded + status = 'degraded'; + graphApi = { + connected: false, + error: graphResult.error, + baseUrl: config.graphApi.baseUrl, + timeoutMs: config.graphApi.timeoutMs + }; + } + res.json({ - status: health.connected ? 'ok' : 'degraded', - neo4j: { - connected: health.connected, - services: health.services, - error: health.error + status, + provider: 'graph-engine', + graphApi, + config: { + maxTraversalDepth: config.simulation.maxTraversalDepth, + defaultLatencyMetric: config.simulation.defaultLatencyMetric + }, + telemetry: { + enabled: config.telemetry.enabled, + workerEnabled: config.telemetryWorker.enabled }, - uptime + uptimeSeconds }); } catch (error) { - res.status(500).json({ - status: 'error', - error: error.message + // Always return 200 even on error, with degraded status + res.json({ + status: 'degraded', + provider: 'graph-engine', + error: error.message, + uptimeSeconds: Math.round((Date.now() - startTime) / 100) / 10 }); } }); +/** + * GET /services + * List all discovered services from the graph with pod-level placement metrics + * Returns normalized serviceId (namespace:name) for UI consumption + * Includes pod-level container metrics (ramUsedMB, cpuUsagePercent) and node-level resources + */ +app.get('/services', async (req, res) => { + try { + // Fetch services with placement and health in parallel + const [servicesResult, healthResult] = await Promise.all([ + getServicesWithPlacement(), + checkGraphHealth() + ]); + + // Extract freshness info from health result + let stale = true; + let lastUpdatedSecondsAgo = null; + let windowMinutes = 5; + + if (healthResult.ok && healthResult.data) { + stale = healthResult.data.stale ?? true; + lastUpdatedSecondsAgo = healthResult.data.lastUpdatedSecondsAgo ?? null; + windowMinutes = healthResult.data.windowMinutes ?? 5; + } + + // Handle services fetch failure + if (!servicesResult.ok) { + return res.status(503).json({ + error: servicesResult.error || 'Failed to fetch services from Graph Engine', + services: [], + count: 0, + stale: true, + lastUpdatedSecondsAgo: null, + windowMinutes + }); + } + + // Process Services with Placement Data + const rawServices = servicesResult.data?.services || []; + + const services = rawServices.map(svc => ({ + serviceId: `${svc.namespace || 'default'}:${svc.name}`, + name: svc.name, + namespace: svc.namespace || 'default', + ...(svc.podCount !== undefined && { podCount: svc.podCount }), + ...(svc.availability !== undefined && { availability: svc.availability }), + ...(svc.placement && { placement: svc.placement }) + })); + + res.json({ + services, + count: services.length, + stale, + lastUpdatedSecondsAgo, + windowMinutes + }); + + } catch (error) { + // Graph Engine unreachable - return 503 with empty services + res.status(503).json({ + error: error.message || 'Graph Engine unreachable', + services: [], + count: 0, + stale: true, + lastUpdatedSecondsAgo: null, + windowMinutes: 5 + }); + } +}); + +// Rate limiter for simulation endpoints +const simulationRateLimiter = rateLimitMiddleware(); + /** * POST /simulate/failure * Simulate failure of a service and report impact @@ -53,34 +192,89 @@ app.get('/health', async (req, res) => { * - namespace: string (optional, with name) * - maxDepth: number (optional, default from config) */ -app.post('/simulate/failure', async (req, res) => { +app.post('/simulate/failure', simulationRateLimiter, async (req, res) => { try { - // Validate and parse request - const identifier = parseServiceIdentifier(req.body); - const maxDepth = validateDepth( - req.body.maxDepth, - config.simulation.maxTraversalDepth, - config.simulation.maxTraversalDepth - ); - + // Parse trace options from query string + const traceOptions = parseTraceOptions(req.query); + const trace = createTrace(traceOptions); + + // Validate and parse request (inside trace stage) + const { identifier, maxDepth: resolvedMaxDepth } = await trace.stage('scenario-parse', async () => { + const id = parseServiceIdentifier(req.body); + const depth = validateDepth( + req.body.maxDepth, + config.simulation.maxTraversalDepth, + config.simulation.maxTraversalDepth + ); + return { identifier: id, maxDepth: depth }; + }); + + // Add scenario-parse summary to trace + trace.setSummary('scenario-parse', { + serviceIdResolved: identifier.serviceId, + maxDepth: resolvedMaxDepth + }); + // Execute simulation with timeout const simulationPromise = simulateFailure({ serviceId: identifier.serviceId, - maxDepth + maxDepth: resolvedMaxDepth + }, { + traceOptions, + trace, + correlationId: req.correlationId }); - + const timeoutPromise = new Promise((_, reject) => { setTimeout( () => reject(new Error('Simulation timeout exceeded')), config.simulation.timeoutMs ); }); - + const result = await Promise.race([simulationPromise, timeoutPromise]); - + + // Add correlationId to body only when trace enabled + if (traceOptions.trace && req.correlationId) { + result.correlationId = req.correlationId; + } + + // Auto-log decision to SQLite (best-effort, silent failure) + const decisionStore = getDecisionStore(); + if (decisionStore) { + try { + const inserted = decisionStore.logDecision({ + timestamp: new Date().toISOString(), + type: 'failure', + scenario: { + serviceId: identifier.serviceId, + maxDepth: resolvedMaxDepth + }, + result: { + totalLostTrafficRps: result.totalLostTrafficRps, + affectedCallersCount: result.affectedCallers?.length || 0, + affectedDownstreamCount: result.affectedDownstream?.length || 0, + unreachableCount: result.unreachableServices?.length || 0, + confidence: result.confidence + }, + correlationId: req.correlationId + }); + + // Debug logging (guarded by env var) + if (process.env.DEBUG_DECISIONS === 'true') { + console.log(`[DecisionStore Debug] Auto-logged failure: id=${inserted.id}, serviceId=${identifier.serviceId}`); + } + } catch (error_) { + console.error('[DecisionStore] Auto-log failed (non-blocking):', error_.message); + } + } + res.json(result); } catch (error) { - if (error.message.includes('not found')) { + // Handle errors with explicit statusCode (e.g., stale graph data) + if (error.statusCode) { + res.status(error.statusCode).json({ error: error.message }); + } else if (error.message.includes('not found')) { res.status(404).json({ error: error.message }); } else if (error.message.includes('timeout')) { res.status(504).json({ error: error.message }); @@ -102,49 +296,115 @@ app.post('/simulate/failure', async (req, res) => { * - name: string (optional, with namespace) * - namespace: string (optional, with name) * - currentPods: number (required) - * - newPods: number (required) + * - newPods: number (required, aliases: targetPods, pods) * - latencyMetric: string (optional, p50/p95/p99) * - model: object (optional, { type: 'bounded_sqrt', alpha: 0.5 }) * - maxDepth: number (optional, default from config) */ -app.post('/simulate/scale', async (req, res) => { +app.post('/simulate/scale', simulationRateLimiter, async (req, res) => { try { - // Validate and parse request - const identifier = parseServiceIdentifier(req.body); - validateScalingParams(req.body.currentPods, req.body.newPods); - const latencyMetric = validateLatencyMetric( - req.body.latencyMetric, - config.simulation.defaultLatencyMetric - ); - const maxDepth = validateDepth( - req.body.maxDepth, - config.simulation.maxTraversalDepth, - config.simulation.maxTraversalDepth - ); - const model = validateScalingModel(req.body.model); - + // Parse trace options from query string + const traceOptions = parseTraceOptions(req.query); + const trace = createTrace(traceOptions); + + // Validate and parse request (inside trace stage) + const { identifier, newPods, latencyMetric: resolvedLatencyMetric, maxDepth: resolvedMaxDepth, model: resolvedModel } = await trace.stage('scenario-parse', async () => { + const id = parseServiceIdentifier(req.body); + const pods = normalizePodParams(req.body); + validateScalingParams(req.body.currentPods, pods); + const metric = validateLatencyMetric( + req.body.latencyMetric, + config.simulation.defaultLatencyMetric + ); + const depth = validateDepth( + req.body.maxDepth, + config.simulation.maxTraversalDepth, + config.simulation.maxTraversalDepth + ); + const m = validateScalingModel(req.body.model); + return { + identifier: id, + newPods: pods, + latencyMetric: metric, + maxDepth: depth, + model: m + }; + }); + + // Add scenario-parse summary to trace + trace.setSummary('scenario-parse', { + serviceIdResolved: identifier.serviceId, + maxDepth: resolvedMaxDepth, + latencyMetric: resolvedLatencyMetric, + model: resolvedModel + }); + // Execute simulation with timeout const simulationPromise = simulateScaling({ serviceId: identifier.serviceId, currentPods: req.body.currentPods, - newPods: req.body.newPods, - latencyMetric, - model, - maxDepth + newPods, + latencyMetric: resolvedLatencyMetric, + model: resolvedModel, + maxDepth: resolvedMaxDepth + }, { + traceOptions, + trace, + correlationId: req.correlationId }); - + const timeoutPromise = new Promise((_, reject) => { setTimeout( () => reject(new Error('Simulation timeout exceeded')), config.simulation.timeoutMs ); }); - + const result = await Promise.race([simulationPromise, timeoutPromise]); - + + // Add correlationId to body only when trace enabled + if (traceOptions.trace && req.correlationId) { + result.correlationId = req.correlationId; + } + + // Auto-log decision to SQLite (best-effort, silent failure) + const decisionStore = getDecisionStore(); + if (decisionStore) { + try { + const inserted = decisionStore.logDecision({ + timestamp: new Date().toISOString(), + type: 'scaling', + scenario: { + serviceId: identifier.serviceId, + currentPods: req.body.currentPods, + newPods, + latencyMetric: resolvedLatencyMetric, + maxDepth: resolvedMaxDepth + }, + result: { + predictedLatencyReduction: result.predictedLatencyReduction, + latencyMetric: result.latencyMetric, + affectedDownstreamCount: result.affectedDownstream?.length || 0, + confidence: result.confidence + }, + correlationId: req.correlationId + }); + + // Debug logging (guarded by env var) + if (process.env.DEBUG_DECISIONS === 'true') { + console.log(`[DecisionStore Debug] Auto-logged scaling: id=${inserted.id}, serviceId=${identifier.serviceId}`); + } + } catch (error_) { + console.error('[DecisionStore] Auto-log failed (non-blocking):', error_.message); + } + } + res.json(result); } catch (error) { - if (error.message.includes('not found')) { + // Handle errors with explicit statusCode (e.g., stale graph data) + if (error.statusCode) { + res.status(error.statusCode).json({ error: error.message }); + } else if (error.message.includes('not found')) { res.status(404).json({ error: error.message }); } else if (error.message.includes('timeout')) { res.status(504).json({ error: error.message }); @@ -157,22 +417,125 @@ app.post('/simulate/scale', async (req, res) => { } }); +/** + * POST /simulate/add + * Simulate adding a new service (resource fit analysis) + * + * Request body: + * - serviceName: string + * - cpuRequest: number (cores, default 0.1) + * - ramRequest: number (MB, default 128) + * - replicas: number (default 1) + */ +app.post('/simulate/add', simulationRateLimiter, async (req, res) => { + try { + const result = await simulateAdd(req.body); + + // Auto-log decision to SQLite (best-effort) + const decisionStore = getDecisionStore(); + if (decisionStore) { + try { + const inserted = decisionStore.logDecision({ + timestamp: new Date().toISOString(), + type: 'add', + scenario: { + serviceName: req.body.serviceName, + cpuRequest: req.body.cpuRequest, + ramRequest: req.body.ramRequest, + replicas: req.body.replicas + }, + result: { + recommendation: result.recommendation, + success: result.success, + confidence: result.confidence + }, + correlationId: req.correlationId + }); + if (process.env.DEBUG_DECISIONS === 'true') { + console.log(`[DecisionStore Debug] Auto-logged add: id=${inserted.id}`); + } + } catch (error_) { + console.error('[DecisionStore] Auto-log failed:', error_.message); + } + } + + res.json(result); + } catch (error) { + console.error('Simulation error:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +/** + * GET /risk/services/top + * Get top services by risk (based on centrality metrics) + * + * Query params: + * - metric: string (optional, 'pagerank' or 'betweenness', default: 'pagerank') + * - limit: number (optional, 1-20, default: 5) + */ +app.get('/risk/services/top', async (req, res) => { + try { + const metric = req.query.metric || 'pagerank'; + const limit = Math.min(Math.max(Number.parseInt(req.query.limit) || 5, 1), 20); + + const result = await getTopRiskServices({ metric, limit }); + + res.json(result); + } catch (error) { + if (error.message.includes('Invalid metric')) { + res.status(400).json({ error: error.message }); + } else if (error.message.includes('disabled')) { + res.status(503).json({ error: 'Graph API is not enabled' }); + } else if (error.message.toLowerCase().includes('timeout')) { + res.status(504).json({ error: 'Graph API timeout' }); + } else { + console.error('Risk analysis error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } +}); + +// Decision logging routes +const decisionsRouter = require('./src/routes/decisions'); +app.use('/decisions', decisionsRouter); + +// Telemetry query routes +const telemetryRouter = require('./src/routes/telemetry'); +app.use('/telemetry', telemetryRouter); + // Start server const server = app.listen(config.server.port, () => { - console.log(`[${new Date().toISOString()}] What-if Simulation Engine started`); + console.log(`[${new Date().toISOString()}] Predictive Analysis Engine started`); console.log(`Port: ${config.server.port}`); console.log(`Max traversal depth: ${config.simulation.maxTraversalDepth}`); console.log(`Default latency metric: ${config.simulation.defaultLatencyMetric}`); console.log(`Scaling model: ${config.simulation.scalingModel} (alpha: ${config.simulation.scalingAlpha})`); console.log(`Timeout: ${config.simulation.timeoutMs}ms`); + + // Initialize DecisionStore singleton at startup + getDecisionStore(); + + // Start background telemetry poll worker + const pollWorker = getWorker(); + pollWorker.start(); }); // Graceful shutdown const shutdown = async () => { console.log('\nShutting down service...'); + + // Stop poll worker + const pollWorker = getWorker(); + await pollWorker.stop(); + + // Close decision store + await closeDecisionStore(); + server.close(); - await closeDriver(); - console.log('Neo4j connection closed. Bye.'); + const provider = getProvider(); + await provider.close(); + console.log('Provider connection closed. Bye.'); process.exit(0); }; diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..8abfe4c --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,2037 @@ +openapi: 3.1.0 +info: + title: Predictive Analysis Engine API + summary: Predictive analysis for microservice failure and scaling scenarios + description: | + API for simulating failure and scaling scenarios in microservice call graphs. + + **Data Source:** + - Graph Engine API (service-graph-engine) - single source of truth for topology and metrics + + **Note:** Swagger UI is disabled by default. Set `ENABLE_SWAGGER=true` to enable. + version: 1.4.1 + contact: + name: Team Alpha Zero + license: + identifier: ISC + name: ISC + +servers: + - url: http://localhost:5000 + description: Local development server + +externalDocs: + description: Project documentation and usage guide + url: https://github.com/Team-Alpha-Zero/predictive-analysis-engine + +tags: + - name: Health + description: Health check and connectivity status + - name: Simulation + description: Failure and scaling simulation endpoints + - name: Risk + description: Risk analysis and centrality-based scoring + - name: Services + description: Service discovery endpoints + - name: Telemetry + description: Time-series metrics from InfluxDB + - name: Decisions + description: Decision logging and history + - name: Graph + description: Dependency graph snapshot with telemetry + +paths: + /health: + get: + tags: + - Health + summary: Health check endpoint + description: Returns data source connectivity status and configuration info + operationId: getHealth + responses: + '200': + description: Health status response + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + example: + status: ok + provider: graph-engine + graphApi: + connected: true + status: ok + stale: false + lastUpdatedSecondsAgo: 45 + baseUrl: http://service-graph-engine:8080 + timeoutMs: 5000 + config: + maxTraversalDepth: 2 + defaultLatencyMetric: p95 + telemetry: + enabled: true + workerEnabled: true + uptimeSeconds: 123.4 + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + status: error + error: Connection failed + + /services: + get: + tags: + - Services + summary: List discovered services + description: | + Returns all services discovered in the current graph snapshot. + Each service includes a normalized serviceId (namespace:name) for UI consumption. + Includes freshness metadata from the graph engine. + + **Container-Level Metrics:** + Each service includes placement information showing which Kubernetes nodes host its pods, + along with pod-level resource metrics: + - ramUsedMB: Pod RAM usage in MB (aggregated from all containers) + - cpuUsagePercent: Pod CPU usage as percentage of node's total cores + + Node-level resource metrics (CPU and RAM) are also included for context. + operationId: listServices + responses: + '200': + description: List of services with freshness info + content: + application/json: + schema: + $ref: '#/components/schemas/ServicesListResponse' + example: + services: + - serviceId: "default:frontend" + name: "frontend" + namespace: "default" + podCount: 1 + availability: 1 + placement: + nodes: + - node: "minikube" + resources: + cpu: + usagePercent: 7.96 + cores: 8 + ram: + usedMB: 8107.19 + totalMB: 24026.4 + pods: + - name: "frontend-75d897db69-dmtzh" + ramUsedMB: 59.65 + cpuUsagePercent: 0.27 + - serviceId: "default:checkoutservice" + name: "checkoutservice" + namespace: "default" + podCount: 1 + availability: 1 + placement: + nodes: + - node: "minikube" + resources: + cpu: + usagePercent: 7.96 + cores: 8 + ram: + usedMB: 8107.19 + totalMB: 24026.4 + pods: + - name: "checkoutservice-57dd9cf79b-28dx6" + ramUsedMB: 52.06 + cpuUsagePercent: 0.03 + uptimeSeconds: 12450 + count: 2 + stale: false + lastUpdatedSecondsAgo: 45 + windowMinutes: 5 + '503': + description: Graph Engine unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/ServicesListResponse' + example: + error: "Graph Engine unreachable" + services: [] + count: 0 + stale: true + lastUpdatedSecondsAgo: null + windowMinutes: 5 + + /simulate/failure: + post: + tags: + - Simulation + summary: Simulate service failure + description: | + Simulate failure of a service and report the impact on upstream callers, + downstream dependents, and potentially unreachable services. + + **Determinism Guarantee:** For the same input and graph snapshot, this endpoint + returns identical results. The algorithm uses deterministic BFS traversal and + fixed sorting criteria (no randomness). + + **Pipeline Trace:** Use `?trace=true` to include detailed execution trace in response. + operationId: simulateFailure + parameters: + - name: trace + in: query + description: Enable pipeline trace (includes stage-by-stage execution details) + required: false + schema: + type: boolean + default: false + - name: includeSnapshot + in: query + description: Include snapshot metadata in trace (requires trace=true) + required: false + schema: + type: boolean + default: false + - name: includeRawPaths + in: query + description: Include raw path data structures in trace (requires trace=true) + required: false + schema: + type: boolean + default: false + - name: includeEdgeDetails + in: query + description: Include detailed edge metadata in trace (requires trace=true) + required: false + schema: + type: boolean + default: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FailureSimulationRequest' + examples: + byServiceId: + summary: Using serviceId + value: + serviceId: "default:frontend" + maxDepth: 2 + byNameNamespace: + summary: Using name and namespace + value: + name: frontend + namespace: default + maxDepth: 2 + responses: + '200': + description: Failure simulation result + content: + application/json: + schema: + $ref: '#/components/schemas/FailureSimulationResponse' + example: + target: + serviceId: "default:frontend" + name: frontend + namespace: default + neighborhood: + description: "k-hop neighborhood subgraph around target (not full graph)" + serviceCount: 8 + edgeCount: 12 + depthUsed: 2 + generatedAt: "2024-01-15T10:30:00.000Z" + dataFreshness: + source: graph-engine + stale: false + lastUpdatedSecondsAgo: 30 + windowMinutes: 5 + confidence: high + explanation: "If frontend fails, 3 upstream caller(s) lose direct access, 2 downstream service(s) lose traffic from this target, and 1 service(s) may become unreachable within the 2-hop neighborhood." + affectedCallers: + - serviceId: "default:loadgenerator" + name: loadgenerator + namespace: default + lostTrafficRps: 150.5 + edgeErrorRate: 0.02 + affectedDownstream: + - serviceId: "default:cartservice" + name: cartservice + namespace: default + lostTrafficRps: 100.0 + edgeErrorRate: 0.01 + unreachableServices: [] + criticalPathsToTarget: + - path: ["default:loadgenerator", "default:frontend"] + pathRps: 150.5 + totalLostTrafficRps: 150.5 + recommendations: + - type: add_retry + priority: high + description: "Add retry logic for high-traffic callers" + '400': + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: "serviceId or (name + namespace) must be provided" + '404': + description: Service not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: "Service 'unknown-service' not found in graph" + '503': + description: Data source unavailable or stale + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: "Graph data is stale (last update: 600s ago). Simulation results may be inaccurate." + '504': + description: Simulation timeout + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: "Simulation timeout exceeded" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: "Internal server error" + + /simulate/add: + post: + tags: + - Simulation + summary: Simulate adding a new service + description: | + Analyze cluster capacity to determine if a new service with specified resource requirements + can be added. Returns placement recommendations based on available node resources. + + **Time-Based Analysis:** + - By default, uses current node capacity snapshot + - Optional `timeWindow` parameter enables historical averaging (e.g., '1w' for past week) + - Historical mode reduces impact of temporary spikes in resource usage + + **Dependency Risk Analysis:** + - Optional `dependencies` array enables risk scoring based on missing/existing dependencies + operationId: simulateAdd + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddSimulationRequest' + examples: + basic: + summary: Basic capacity check + value: + serviceName: new-service + cpuRequest: 0.5 + ramRequest: 512 + replicas: 3 + withTimeWindow: + summary: With historical time window + value: + serviceName: new-service + cpuRequest: 0.5 + ramRequest: 512 + replicas: 3 + timeWindow: "1w" + withDependencies: + summary: With dependency risk analysis + value: + serviceName: new-service + cpuRequest: 0.5 + ramRequest: 512 + replicas: 3 + dependencies: + - serviceId: "default:cartservice" + relation: calls + - serviceId: "default:redis" + relation: calls + responses: + '200': + description: Placement analysis result + content: + application/json: + schema: + $ref: '#/components/schemas/AddSimulationResponse' + example: + success: true + confidence: high + explanation: "Successfully placed 3 replica(s) across 2 node(s)." + totalCapacityPods: 10 + nodeAnalysis: + - node: minikube + cpuAvailable: 2.5 + ramAvailableMB: 4096.0 + canFit: true + maxPods: 5 + recommendation: + serviceName: new-service + cpuRequest: 0.5 + ramRequest: 512 + distribution: + - node: minikube + replicas: 3 + '400': + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /simulate/scale: + post: + tags: + - Simulation + summary: Simulate service scaling + description: | + Simulate scaling a service (changing pod count) and report the latency impact + on upstream callers and critical paths. + + **Determinism Guarantee:** For the same input and graph snapshot, this endpoint + returns identical results. The algorithm uses deterministic formulas and fixed + traversal order (no randomness). + + **Scaling Models:** + - **bounded_sqrt** (default): Realistic model with diminishing returns. + Formula: `newLatency = baseLatency * (alpha + (1-alpha) / sqrt(ratio))` + where ratio = newPods/currentPods, alpha = fixed overhead fraction (default 0.5). + Result is clamped to minLatencyFactor * baseLatency (default 60% of baseline). + - **linear**: Optimistic model assuming perfect scaling. + Formula: `newLatency = baseLatency * (currentPods / newPods)` + Useful for best-case estimates; no overhead assumption. + + **Pipeline Trace:** Use `?trace=true` to include detailed execution trace in response. + operationId: simulateScale + parameters: + - name: trace + in: query + description: Enable pipeline trace (includes stage-by-stage execution details) + required: false + schema: + type: boolean + default: false + - name: includeSnapshot + in: query + description: Include snapshot metadata in trace (requires trace=true) + required: false + schema: + type: boolean + default: false + - name: includeRawPaths + in: query + description: Include raw path data structures in trace (requires trace=true) + required: false + schema: + type: boolean + default: false + - name: includeEdgeDetails + in: query + description: Include detailed edge metadata in trace (requires trace=true) + required: false + schema: + type: boolean + default: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ScalingSimulationRequest' + examples: + scaleUp: + summary: Scale up from 2 to 4 pods + value: + serviceId: "default:cartservice" + currentPods: 2 + newPods: 4 + latencyMetric: p95 + maxDepth: 2 + withModel: + summary: With custom scaling model + value: + name: cartservice + namespace: default + currentPods: 2 + targetPods: 6 + latencyMetric: p99 + model: + type: bounded_sqrt + alpha: 0.3 + maxDepth: 2 + responses: + '200': + description: Scaling simulation result + content: + application/json: + schema: + $ref: '#/components/schemas/ScalingSimulationResponse' + example: + target: + serviceId: "default:cartservice" + name: cartservice + namespace: default + neighborhood: + description: "k-hop upstream subgraph around target (not full graph)" + serviceCount: 5 + edgeCount: 8 + depthUsed: 2 + generatedAt: "2024-01-15T10:30:00.000Z" + dataFreshness: + source: graph-engine + stale: false + lastUpdatedSecondsAgo: 30 + windowMinutes: 5 + confidence: high + latencyMetric: p95 + scalingModel: + type: bounded_sqrt + alpha: 0.5 + currentPods: 2 + newPods: 4 + latencyEstimate: + description: "Rate-weighted mean of incoming edge latency to target" + baselineMs: 120.5 + projectedMs: 85.2 + deltaMs: -35.3 + unit: milliseconds + affectedCallers: + description: "Edge-level impact: deltaMs is change in this caller's direct outgoing edge latency. endToEndDeltaMs is cumulative path latency change." + items: + - serviceId: "default:frontend" + name: frontend + namespace: default + hopDistance: 1 + beforeMs: 120.5 + afterMs: 85.2 + deltaMs: -35.3 + endToEndBeforeMs: 120.5 + endToEndAfterMs: 85.2 + endToEndDeltaMs: -35.3 + viaPath: ["default:frontend", "default:cartservice"] + affectedPaths: + - path: ["default:frontend", "default:cartservice"] + pathRps: 100.0 + beforeMs: 120.5 + afterMs: 85.2 + deltaMs: -35.3 + incompleteData: false + recommendations: + - type: scale_complete + priority: medium + description: "Scaling will improve latency by ~29%" + '400': + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: "currentPods must be a positive integer" + '404': + description: Service not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: "Service 'unknown-service' not found in graph" + '503': + description: Data source unavailable or stale + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '504': + description: Simulation timeout + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /risk/services/top: + get: + tags: + - Risk + summary: Get top risk services + description: | + Get services ranked by risk based on centrality metrics (PageRank or betweenness). + Higher centrality = higher risk if the service fails. + operationId: getTopRiskServices + parameters: + - name: metric + in: query + description: Centrality metric to use for ranking + required: false + schema: + type: string + enum: + - pagerank + - betweenness + default: pagerank + - name: limit + in: query + description: Number of services to return (1-20) + required: false + schema: + type: integer + minimum: 1 + maximum: 20 + default: 5 + responses: + '200': + description: Top risk services + content: + application/json: + schema: + $ref: '#/components/schemas/RiskAnalysisResponse' + example: + metric: pagerank + services: + - serviceId: "default:frontend" + name: frontend + namespace: default + centralityScore: 0.2534 + riskLevel: high + explanation: "frontend has high PageRank (0.2534), indicating it is a critical hub. Failure could cascade widely." + - serviceId: "default:cartservice" + name: cartservice + namespace: default + centralityScore: 0.1823 + riskLevel: medium + explanation: "cartservice has moderate PageRank (0.1823). Monitor for dependencies." + dataFreshness: + source: graph-engine + stale: false + lastUpdatedSecondsAgo: 30 + windowMinutes: 5 + confidence: high + '400': + description: Invalid metric parameter + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: "Invalid metric: invalid. Allowed: pagerank, betweenness" + '503': + description: Graph API not enabled + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: "Graph API is not enabled" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /telemetry/service: + get: + tags: + - Telemetry + summary: Get time-series metrics for a service + description: | + Retrieves time-series metrics for a specific service from InfluxDB 3. + Maximum time range is 7 days. Returns data points bucketed by step interval. + If service parameter is omitted, returns metrics for all services. + operationId: getTelemetryService + parameters: + - name: service + in: query + required: false + description: Service name to query metrics for (optional). If omitted, returns metrics for all services. + schema: + type: string + example: productcatalogservice + - name: from + in: query + required: true + description: Start timestamp (ISO 8601) + schema: + type: string + format: date-time + example: '2026-01-04T10:00:00Z' + - name: to + in: query + required: true + description: End timestamp (ISO 8601) + schema: + type: string + format: date-time + example: '2026-01-04T11:00:00Z' + - name: step + in: query + required: false + description: Time bucket size in seconds (default 60) + schema: + type: integer + default: 60 + responses: + '200': + description: Time-series metrics data + content: + application/json: + schema: + $ref: '#/components/schemas/TelemetryServiceResponse' + '400': + description: Invalid parameters or time range exceeds limit + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: InfluxDB not configured + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /telemetry/edges: + get: + tags: + - Telemetry + summary: Get time-series metrics for edges + description: | + Retrieves time-series metrics for edges (calls between services) from InfluxDB 3. + Maximum time range is 7 days. Can filter by source/destination service. + operationId: getTelemetryEdges + parameters: + - name: fromService + in: query + required: false + description: Source service name (optional filter) + schema: + type: string + - name: toService + in: query + required: false + description: Destination service name (optional filter) + schema: + type: string + - name: from + in: query + required: true + description: Start timestamp (ISO 8601) + schema: + type: string + format: date-time + - name: to + in: query + required: true + description: End timestamp (ISO 8601) + schema: + type: string + format: date-time + - name: step + in: query + required: false + description: Time bucket size in seconds (default 60) + schema: + type: integer + default: 60 + responses: + '200': + description: Time-series edge metrics data + content: + application/json: + schema: + $ref: '#/components/schemas/TelemetryEdgesResponse' + '400': + description: Invalid parameters or time range exceeds limit + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: InfluxDB not configured + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /decisions/log: + post: + tags: + - Decisions + summary: Log a decision from Pipeline Playground + description: | + Stores a decision record in SQLite for audit trail and analysis. + Used by Pipeline Playground to log simulation decisions. + operationId: logDecision + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LogDecisionRequest' + example: + timestamp: '2026-01-04T10:00:00Z' + type: failure + scenario: + serviceId: 'productcatalogservice' + maxDepth: 2 + result: + totalLostTrafficRps: 150.5 + affectedCallers: 5 + correlationId: 'abc-123-def' + responses: + '201': + description: Decision logged successfully + content: + application/json: + schema: + $ref: '#/components/schemas/LogDecisionResponse' + '400': + description: Invalid request body + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: SQLite not configured or unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/dependency-graph/snapshot: + get: + tags: + - Graph + summary: Get enriched dependency graph snapshot with telemetry + description: | + Returns the complete dependency graph with enriched node and edge telemetry data. + This endpoint is optimized for the Incident Explorer UI to provide comprehensive + graph visualization data in a single request. + + **Node Data Includes:** + - Service identity (id, name, namespace) + - Risk level and reason (CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN) + - Aggregated telemetry metrics (request rate, error rate, latency, availability) + + **Edge Data Includes:** + - Source and target service IDs + - Optional edge telemetry (if available from Graph Engine) + + **Metadata Includes:** + - Data freshness information + - Last update timestamp + - Total node and edge counts + operationId: getDependencyGraphSnapshot + parameters: + - name: range + in: query + required: false + description: Time range for metrics (informational only, currently not implemented) + schema: + type: string + example: "1h" + - name: namespace + in: query + required: false + description: Filter nodes by namespace + schema: + type: string + example: "default" + responses: + '200': + description: Enriched graph snapshot with nodes, edges, and metadata + content: + application/json: + schema: + $ref: '#/components/schemas/GraphSnapshotResponse' + example: + nodes: + - id: "default:frontend" + name: "frontend" + namespace: "default" + riskLevel: "LOW" + riskReason: "Operating normally" + reqRate: 150.5 + errorRatePct: 0.2 + latencyP95Ms: 45.3 + availabilityPct: 99.9 + updatedAt: "2026-01-04T10:00:00Z" + - id: "default:backend" + name: "backend" + namespace: "default" + riskLevel: "MEDIUM" + riskReason: "Elevated error rate (1.5%)" + reqRate: 200.0 + errorRatePct: 1.5 + latencyP95Ms: 120.0 + availabilityPct: 99.5 + updatedAt: "2026-01-04T10:00:00Z" + edges: + - id: "default:frontend->default:backend" + source: "default:frontend" + target: "default:backend" + reqRate: 150.5 + errorRatePct: 0.5 + latencyP95Ms: 55.2 + metadata: + stale: false + lastUpdatedSecondsAgo: 30 + windowMinutes: 5 + nodeCount: 2 + edgeCount: 1 + generatedAt: "2026-01-04T10:00:00Z" + '503': + description: Graph Engine unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/GraphSnapshotErrorResponse' + example: + error: "Failed to fetch graph snapshot from Graph Engine" + nodes: [] + edges: [] + metadata: + stale: true + lastUpdatedSecondsAgo: null + windowMinutes: 5 + + /decisions/history: + get: + tags: + - Decisions + summary: Get decision history logs + description: | + Retrieves decision logs from SQLite with pagination and optional type filter. + Returns most recent decisions first. + operationId: getDecisionHistory + parameters: + - name: limit + in: query + required: false + description: Page size (max 100, default 50) + schema: + type: integer + default: 50 + maximum: 100 + - name: offset + in: query + required: false + description: Pagination offset (default 0) + schema: + type: integer + default: 0 + - name: type + in: query + required: false + description: Filter by decision type (failure, scaling, risk) + schema: + type: string + enum: [failure, scaling, risk] + responses: + '200': + description: Decision history with pagination metadata + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionHistoryResponse' + '503': + description: SQLite not configured or unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + +components: + schemas: + ErrorResponse: + type: object + description: Standard error response returned by all endpoints on failure + required: + - error + example: + error: "Service 'unknown-service' not found in graph" + properties: + error: + type: string + description: Human-readable error message describing what went wrong + + HealthResponse: + type: object + properties: + status: + type: string + enum: [ok, degraded, error] + provider: + type: string + enum: [graph-engine] + description: Data provider identifier + graphApi: + type: object + properties: + connected: + type: boolean + status: + type: string + stale: + type: boolean + lastUpdatedSecondsAgo: + type: number + baseUrl: + type: string + timeoutMs: + type: integer + error: + type: string + nullable: true + config: + type: object + properties: + maxTraversalDepth: + type: integer + defaultLatencyMetric: + type: string + telemetry: + type: object + properties: + enabled: + type: boolean + description: Whether telemetry collection is enabled + workerEnabled: + type: boolean + description: Whether background telemetry polling worker is enabled + uptimeSeconds: + type: number + + ServicesListResponse: + type: object + description: List of discovered services with freshness metadata + properties: + services: + type: array + description: List of services from the current graph snapshot + items: + $ref: '#/components/schemas/DiscoveredService' + count: + type: integer + description: Total number of services discovered + example: 12 + stale: + type: boolean + description: Whether the graph data is stale (older than expected window) + example: false + lastUpdatedSecondsAgo: + type: number + description: Seconds since last graph update (null if unavailable) + nullable: true + example: 45 + windowMinutes: + type: number + description: Expected freshness window in minutes + example: 5 + error: + type: string + description: Error message if service discovery failed + nullable: true + + DiscoveredService: + type: object + description: A service discovered in the graph with pod placement and container-level metrics + required: + - serviceId + - name + - namespace + properties: + serviceId: + type: string + description: "Canonical service ID in format 'namespace:name'" + example: "default:frontend" + name: + type: string + description: Service name + example: "frontend" + namespace: + type: string + description: Kubernetes namespace + example: "default" + podCount: + type: integer + description: Total number of pods running for this service + example: 3 + availability: + type: number + description: Service availability score (0-1) + example: 1 + placement: + type: object + description: Pod placement information across Kubernetes nodes with container-level resource metrics + properties: + nodes: + type: array + description: List of nodes hosting this service's pods + items: + type: object + properties: + node: + type: string + description: Kubernetes node name + example: "minikube" + resources: + type: object + description: Node-level resource usage + properties: + cpu: + type: object + properties: + usagePercent: + type: number + description: Node CPU usage percentage + example: 7.96 + cores: + type: integer + description: Total CPU cores available on node + example: 8 + ram: + type: object + properties: + usedMB: + type: number + description: RAM used on node in MB + example: 8107.19 + totalMB: + type: number + description: Total RAM available on node in MB + example: 24026.4 + pods: + type: array + description: Pods running on this node with container-level metrics + items: + type: object + properties: + name: + type: string + description: Pod name + example: "frontend-75d897db69-dmtzh" + ramUsedMB: + type: number + description: Pod RAM usage in MB (aggregated from all containers) + example: 59.65 + cpuUsagePercent: + type: number + description: Pod CPU usage as percentage of node's total cores + example: 0.27 + uptimeSeconds: + type: number + description: Pod uptime in seconds + + ServiceIdentifier: + type: object + description: Service can be identified by serviceId OR by name+namespace + example: + serviceId: "default:frontend" + name: frontend + namespace: default + properties: + serviceId: + type: string + description: "Canonical service ID in format 'namespace:name'" + example: "default:frontend" + name: + type: string + description: Service name (use with namespace) + example: frontend + namespace: + type: string + description: Kubernetes namespace (use with name) + example: default + + FailureSimulationRequest: + allOf: + - $ref: '#/components/schemas/ServiceIdentifier' + - type: object + properties: + maxDepth: + type: integer + minimum: 1 + maximum: 3 + default: 2 + description: Maximum traversal depth for impact analysis + + FailureSimulationResponse: + type: object + properties: + target: + $ref: '#/components/schemas/ServiceReference' + neighborhood: + $ref: '#/components/schemas/NeighborhoodInfo' + dataFreshness: + $ref: '#/components/schemas/DataFreshness' + confidence: + type: string + enum: [high, low, unknown] + explanation: + type: string + affectedCallers: + type: array + items: + $ref: '#/components/schemas/AffectedCaller' + affectedDownstream: + type: array + items: + $ref: '#/components/schemas/AffectedDownstream' + unreachableServices: + type: array + items: + $ref: '#/components/schemas/UnreachableService' + criticalPathsToTarget: + type: array + items: + $ref: '#/components/schemas/CriticalPath' + totalLostTrafficRps: + type: number + recommendations: + type: array + items: + $ref: '#/components/schemas/Recommendation' + pipelineTrace: + $ref: '#/components/schemas/PipelineTrace' + description: Optional pipeline trace (included only when trace=true query param is set) + correlationId: + type: string + format: uuid + description: Request correlation ID (included only when trace=true) + + ScalingSimulationRequest: + allOf: + - $ref: '#/components/schemas/ServiceIdentifier' + - type: object + required: + - currentPods + properties: + currentPods: + type: integer + minimum: 1 + description: Current number of pods + newPods: + type: integer + minimum: 1 + description: "Target number of pods (alias: targetPods, pods)" + targetPods: + type: integer + minimum: 1 + description: Alias for newPods + latencyMetric: + type: string + enum: [p50, p95, p99] + default: p95 + description: Latency percentile to use + model: + $ref: '#/components/schemas/ScalingModel' + maxDepth: + type: integer + minimum: 1 + maximum: 3 + default: 2 + description: Maximum traversal depth + + ScalingModel: + type: object + example: + type: bounded_sqrt + alpha: 0.5 + properties: + type: + type: string + enum: [bounded_sqrt, linear] + default: bounded_sqrt + description: Scaling model type + alpha: + type: number + minimum: 0 + maximum: 1 + default: 0.5 + description: Fixed overhead fraction (only for bounded_sqrt) + + ScalingSimulationResponse: + type: object + properties: + target: + $ref: '#/components/schemas/ServiceReference' + neighborhood: + $ref: '#/components/schemas/NeighborhoodInfo' + dataFreshness: + $ref: '#/components/schemas/DataFreshness' + confidence: + type: string + enum: [high, low, unknown] + latencyMetric: + type: string + scalingModel: + $ref: '#/components/schemas/ScalingModel' + currentPods: + type: integer + newPods: + type: integer + scalingDirection: + type: string + enum: [up, down, none] + description: Direction of scaling (up if newPods > currentPods, down if less, none if equal) + explanation: + type: string + description: Human-readable summary of scaling impact including latency change and affected callers + latencyEstimate: + $ref: '#/components/schemas/LatencyEstimate' + affectedCallers: + type: object + properties: + description: + type: string + items: + type: array + items: + $ref: '#/components/schemas/AffectedCallerScaling' + affectedPaths: + type: array + items: + $ref: '#/components/schemas/AffectedPathScaling' + warnings: + type: array + description: Present only when some paths have incomplete latency data + items: + type: string + recommendations: + type: array + items: + $ref: '#/components/schemas/Recommendation' + pipelineTrace: + $ref: '#/components/schemas/PipelineTrace' + description: Optional pipeline trace (included only when trace=true query param is set) + correlationId: + type: string + description: Request correlation ID (included only when trace=true) + + AddSimulationRequest: + type: object + required: + - serviceName + properties: + serviceName: + type: string + description: Name of the service to add + cpuRequest: + type: number + default: 0.1 + description: CPU cores requested per pod + ramRequest: + type: number + default: 128 + description: RAM in MB requested per pod + replicas: + type: integer + default: 1 + description: Number of replicas to deploy + timeWindow: + type: string + description: | + Historical time window for node capacity analysis (optional). + When provided, uses historical averaged metrics instead of current snapshot. + Format: number + unit (e.g., '1h', '24h', '1w', '7d'). + Units: h=hours, d=days, w=weeks. + example: "1w" + dependencies: + type: array + description: List of dependencies for the new service (optional, used for risk analysis) + items: + $ref: '#/components/schemas/ServiceDependency' + + ServiceDependency: + type: object + required: + - serviceId + properties: + serviceId: + type: string + relation: + type: string + enum: [calls, called_by] + default: calls + + AddSimulationResponse: + type: object + properties: + targetServiceName: + type: string + success: + type: boolean + description: Whether the placement is possible for all replicas + confidence: + type: string + enum: [high, low, unknown] + explanation: + type: string + description: Human readable explanation of the result + totalCapacityPods: + type: integer + description: Total number of such pods the cluster can fit + suitableNodes: + type: array + items: + $ref: '#/components/schemas/NodeSuitability' + riskAnalysis: + type: object + properties: + dependencyRisk: + type: string + enum: [low, medium, high] + description: + type: string + recommendations: + type: array + items: + $ref: '#/components/schemas/Recommendation' + + NodeSuitability: + type: object + properties: + nodeName: + type: string + suitable: + type: boolean + reason: + type: string + availableCpu: + type: number + availableRam: + type: number + score: + type: integer + description: Suitability score (0-100) + + AddRecommendation: + type: object + properties: + serviceName: + type: string + cpuRequest: + type: number + ramRequest: + type: number + distribution: + type: array + description: Recommended pod distribution + items: + type: object + properties: + node: + type: string + replicas: + type: integer + + RiskAnalysisResponse: + type: object + properties: + metric: + type: string + enum: [pagerank, betweenness] + services: + type: array + items: + $ref: '#/components/schemas/RiskService' + dataFreshness: + $ref: '#/components/schemas/DataFreshness' + confidence: + type: string + enum: [high, low, unknown] + + RiskService: + type: object + properties: + serviceId: + type: string + name: + type: string + namespace: + type: string + centralityScore: + type: number + riskLevel: + type: string + enum: [high, medium, low] + explanation: + type: string + + ServiceReference: + type: object + properties: + serviceId: + type: string + name: + type: string + namespace: + type: string + + NeighborhoodInfo: + type: object + properties: + description: + type: string + serviceCount: + type: integer + edgeCount: + type: integer + depthUsed: + type: integer + generatedAt: + type: string + format: date-time + + DataFreshness: + type: object + nullable: true + example: + source: graph-engine + stale: false + lastUpdatedSecondsAgo: 30 + windowMinutes: 5 + properties: + source: + type: string + description: Data source identifier (always 'graph-engine') + stale: + type: boolean + description: Whether the data is considered stale + lastUpdatedSecondsAgo: + type: number + description: Seconds since last data refresh + windowMinutes: + type: integer + description: Data aggregation window in minutes + + AffectedCaller: + type: object + properties: + serviceId: + type: string + name: + type: string + namespace: + type: string + lostTrafficRps: + type: number + edgeErrorRate: + type: number + + AffectedDownstream: + type: object + properties: + serviceId: + type: string + name: + type: string + namespace: + type: string + lostTrafficRps: + type: number + edgeErrorRate: + type: number + + UnreachableService: + type: object + properties: + serviceId: + type: string + name: + type: string + namespace: + type: string + lostTrafficRps: + type: number + lostFromTargetRps: + type: number + lostFromReachableCutsRps: + type: number + + CriticalPath: + type: object + properties: + path: + type: array + items: + type: string + pathRps: + type: number + + AffectedCallerScaling: + type: object + properties: + serviceId: + type: string + name: + type: string + namespace: + type: string + hopDistance: + type: integer + beforeMs: + type: number + nullable: true + afterMs: + type: number + nullable: true + deltaMs: + type: number + nullable: true + endToEndBeforeMs: + type: number + nullable: true + endToEndAfterMs: + type: number + nullable: true + endToEndDeltaMs: + type: number + nullable: true + viaPath: + type: array + nullable: true + items: + type: string + + AffectedPathScaling: + type: object + properties: + path: + type: array + items: + type: string + pathRps: + type: number + beforeMs: + type: number + nullable: true + afterMs: + type: number + nullable: true + deltaMs: + type: number + nullable: true + incompleteData: + type: boolean + + LatencyEstimate: + type: object + properties: + description: + type: string + baselineMs: + type: number + nullable: true + projectedMs: + type: number + nullable: true + deltaMs: + type: number + nullable: true + unit: + type: string + + Recommendation: + type: object + properties: + type: + type: string + priority: + type: string + enum: [high, medium, low] + description: + type: string + + PipelineTrace: + type: object + description: Pipeline execution trace (included only when trace=true) + properties: + options: + type: object + description: Echo of trace options used + properties: + trace: + type: boolean + includeSnapshot: + type: boolean + includeRawPaths: + type: boolean + includeEdgeDetails: + type: boolean + stages: + type: array + description: Array of execution stages with timing information + items: + type: object + properties: + name: + type: string + description: Stage identifier (kebab-case) + ms: + type: number + description: Duration in milliseconds + summary: + type: object + description: Optional stage-specific metadata (size-limited) + additionalProperties: true + warnings: + type: array + description: Optional array of warning messages + items: + type: string + generatedAt: + type: string + format: date-time + description: ISO 8601 timestamp when trace was finalized + + TelemetryServiceResponse: + type: object + description: Time-series metrics for a service + required: + - service + - from + - to + - step + - datapoints + properties: + service: + type: string + from: + type: string + format: date-time + to: + type: string + format: date-time + step: + type: integer + datapoints: + type: array + items: + type: object + properties: + timestamp: + type: string + format: date-time + service: + type: string + namespace: + type: string + requestRate: + type: number + errorRate: + type: number + p50: + type: number + p95: + type: number + p99: + type: number + availability: + type: number + + TelemetryEdgesResponse: + type: object + description: Time-series metrics for edges + required: + - from + - to + - step + - datapoints + properties: + fromService: + type: string + toService: + type: string + from: + type: string + format: date-time + to: + type: string + format: date-time + step: + type: integer + datapoints: + type: array + items: + type: object + properties: + timestamp: + type: string + format: date-time + from: + type: string + to: + type: string + namespace: + type: string + requestRate: + type: number + errorRate: + type: number + p50: + type: number + p95: + type: number + p99: + type: number + + LogDecisionRequest: + type: object + description: Request to log a decision + required: + - timestamp + - type + - scenario + - result + properties: + timestamp: + type: string + format: date-time + type: + type: string + enum: [failure, scaling, risk] + scenario: + type: object + additionalProperties: true + result: + type: object + additionalProperties: true + correlationId: + type: string + + LogDecisionResponse: + type: object + description: Response after logging a decision + required: + - id + - timestamp + properties: + id: + type: integer + timestamp: + type: string + format: date-time + + DecisionHistoryResponse: + type: object + description: Decision history with pagination + required: + - decisions + - pagination + properties: + decisions: + type: array + items: + type: object + properties: + id: + type: integer + timestamp: + type: string + format: date-time + type: + type: string + scenario: + type: object + additionalProperties: true + result: + type: object + additionalProperties: true + correlationId: + type: string + nullable: true + createdAt: + type: string + format: date-time + pagination: + type: object + properties: + limit: + type: integer + offset: + type: integer + total: + type: integer + GraphNode: + type: object + description: Enriched graph node with telemetry + required: + - id + - name + - namespace + - riskLevel + properties: + id: + type: string + description: Unique node identifier (namespace:name) + example: "default:frontend" + name: + type: string + description: Service name + example: "frontend" + namespace: + type: string + description: Kubernetes namespace + example: "default" + riskLevel: + type: string + enum: [CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN] + description: Calculated risk level based on metrics + riskReason: + type: string + description: Human-readable explanation of risk level + example: "High error rate (6.2%)" + reqRate: + type: number + description: Requests per second + example: 150.5 + errorRatePct: + type: number + description: Error rate as percentage + example: 0.2 + latencyP95Ms: + type: number + description: 95th percentile latency in milliseconds + example: 45.3 + availabilityPct: + type: number + description: Availability percentage + example: 99.9 + updatedAt: + type: string + format: date-time + description: Timestamp of last metric update + + GraphEdge: + type: object + description: Enriched graph edge with optional telemetry + required: + - id + - source + - target + properties: + id: + type: string + description: Unique edge identifier + example: "default:frontend->default:backend" + source: + type: string + description: Source node ID (namespace:name) + example: "default:frontend" + target: + type: string + description: Target node ID (namespace:name) + example: "default:backend" + reqRate: + type: number + description: Requests per second on this edge + example: 150.5 + errorRatePct: + type: number + description: Error rate as percentage on this edge + example: 0.5 + latencyP95Ms: + type: number + description: 95th percentile latency in milliseconds on this edge + example: 55.2 + + GraphSnapshotMetadata: + type: object + description: Metadata about graph snapshot freshness and size + properties: + stale: + type: boolean + description: Whether the graph data is stale + lastUpdatedSecondsAgo: + type: number + nullable: true + description: Seconds since last update from Graph Engine + windowMinutes: + type: integer + description: Metrics aggregation window in minutes + nodeCount: + type: integer + description: Total number of nodes in snapshot + edgeCount: + type: integer + description: Total number of edges in snapshot + generatedAt: + type: string + format: date-time + description: Timestamp when snapshot was generated + + GraphSnapshotResponse: + type: object + description: Complete dependency graph snapshot with enriched telemetry + required: + - nodes + - edges + properties: + nodes: + type: array + items: + $ref: '#/components/schemas/GraphNode' + edges: + type: array + items: + $ref: '#/components/schemas/GraphEdge' + metadata: + $ref: '#/components/schemas/GraphSnapshotMetadata' + + GraphSnapshotErrorResponse: + type: object + description: Error response when graph snapshot cannot be fetched + required: + - error + - nodes + - edges + - metadata + properties: + error: + type: string + description: Error message + nodes: + type: array + items: + $ref: '#/components/schemas/GraphNode' + description: Empty array on error + edges: + type: array + items: + $ref: '#/components/schemas/GraphEdge' + description: Empty array on error + metadata: + $ref: '#/components/schemas/GraphSnapshotMetadata' \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8318577..7912e15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,428 +1,4437 @@ { - "name": "what-if-simulation-engine", + "name": "predictive-analysis-engine", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "what-if-simulation-engine", + "name": "predictive-analysis-engine", "version": "1.0.0", "license": "ISC", "dependencies": { + "@influxdata/influxdb3-client": "^0.7.0", + "better-sqlite3": "^11.8.1", + "commander": "^11.1.0", "dotenv": "^17.2.3", - "express": "^4.22.1", - "neo4j-driver": "^6.0.1" + "express": "^4.22.1" }, - "devDependencies": {} + "bin": { + "predict": "bin/predict.js" + }, + "devDependencies": { + "@stoplight/spectral-cli": "^6.15.0", + "js-yaml": "^4.1.0", + "nodemon": "^3.1.11", + "swagger-ui-express": "^5.0.1" + } }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", + "node_modules/@asyncapi/specs": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.10.0.tgz", + "integrity": "sha512-vB5oKLsdrLUORIZ5BXortZTlVyGWWMC1Nud/0LtgxQ3Yn2738HigAD6EVqScvpPsDUI/bcLVsYEXN4dtXQHVng==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "@types/json-schema": "^7.0.11" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { - "node": ">= 0.6" + "node": ">=12.10.0" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", + "node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=6" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/@influxdata/influxdb3-client": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb3-client/-/influxdb3-client-0.7.0.tgz", + "integrity": "sha512-QddQxud7RFyqRhiS6xORL2MxBjGW38VMJwLPPmuFE9cF2D2ibIhiKyWmUWDNZrlaEv3c507BYemnr0NLhqXwqg==", "license": "MIT", "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" + "@grpc/grpc-js": "^1.9.9", + "@protobuf-ts/grpc-transport": "^2.9.1", + "@protobuf-ts/grpcweb-transport": "^2.9.1", + "@protobuf-ts/runtime-rpc": "^2.9.1", + "apache-arrow": "^15.0.0", + "grpc-web": "^1.5.0" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "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", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", "license": "MIT", - "engines": { - "node": ">= 0.8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, "engines": { - "node": ">= 0.4" + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" } }, - "node_modules/call-bound": { + "node_modules/@jsep-plugin/regex": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "dev": true, "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" + "engines": { + "node": ">= 10.16.0" }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/ternary": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/ternary/-/ternary-1.1.4.tgz", + "integrity": "sha512-ck5wiqIbqdMX6WRQztBL7ASDty9YLgJ3sSAK5ZpBzXeySvFGCzIvM6UiAI4hTZ22fEcYQVV/zhUbNscggW+Ukg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">= 10.16.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" } }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { - "safe-buffer": "5.2.1" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, "engines": { - "node": ">= 0.6" + "node": ">= 8" } }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 8" } }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, "engines": { - "node": ">= 0.6" + "node": ">= 8" } }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" + "node_modules/@protobuf-ts/grpc-transport": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/grpc-transport/-/grpc-transport-2.11.1.tgz", + "integrity": "sha512-l6wrcFffY+tuNnuyrNCkRM8hDIsAZVLA8Mn7PKdVyYxITosYh60qW663p9kL6TWXYuDCL3oxH8ih3vLKTDyhtg==", + "license": "Apache-2.0", + "dependencies": { + "@protobuf-ts/runtime": "^2.11.1", + "@protobuf-ts/runtime-rpc": "^2.11.1" + }, + "peerDependencies": { + "@grpc/grpc-js": "^1.6.0" + } }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", + "node_modules/@protobuf-ts/grpcweb-transport": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/grpcweb-transport/-/grpcweb-transport-2.11.1.tgz", + "integrity": "sha512-1W4utDdvOB+RHMFQ0soL4JdnxjXV+ddeGIUg08DvZrA8Ms6k5NN6GBFU2oHZdTOcJVpPrDJ02RJlqtaoCMNBtw==", + "license": "Apache-2.0", "dependencies": { - "ms": "2.0.0" + "@protobuf-ts/runtime": "^2.11.1", + "@protobuf-ts/runtime-rpc": "^2.11.1" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" + "node_modules/@protobuf-ts/runtime": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime/-/runtime-2.11.1.tgz", + "integrity": "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@protobuf-ts/runtime-rpc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime-rpc/-/runtime-rpc-2.11.1.tgz", + "integrity": "sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ==", + "license": "Apache-2.0", + "dependencies": { + "@protobuf-ts/runtime": "^2.11.1" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" } }, - "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "license": "BSD-2-Clause", + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.2.tgz", + "integrity": "sha512-//NdP6iIwPbMTcazYsiBMbJW7gfmpHom33u1beiIoHDEM0Q9clvtQB1T0efvMqHeKsGohiHo97BCPCkBXdscwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "commondir": "^1.0.1", + "estree-walker": "^2.0.1", + "glob": "^7.1.6", + "is-reference": "^1.2.1", + "magic-string": "^0.25.7", + "resolve": "^1.17.0" + }, "engines": { - "node": ">=12" + "node": ">= 12.0.0" }, - "funding": { - "url": "https://dotenvx.com" + "peerDependencies": { + "rollup": "^2.68.0" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" }, "engines": { - "node": ">= 0.4" + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true, "license": "MIT" }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0" }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", + "node_modules/@stoplight/better-ajv-errors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@stoplight/better-ajv-errors/-/better-ajv-errors-1.0.3.tgz", + "integrity": "sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, "engines": { - "node": ">= 0.4" + "node": "^12.20 || >= 14.13" + }, + "peerDependencies": { + "ajv": ">=8" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", + "node_modules/@stoplight/json": { + "version": "3.21.7", + "resolved": "https://registry.npmjs.org/@stoplight/json/-/json-3.21.7.tgz", + "integrity": "sha512-xcJXgKFqv/uCEgtGlPxy3tPA+4I+ZI4vAuMJ885+ThkTHFVkC+0Fm58lA9NlsyjnkpxFh4YiQWpH+KefHdbA0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.3", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^13.6.0", + "jsonc-parser": "~2.2.1", + "lodash": "^4.17.21", + "safe-stable-stringify": "^1.1" + }, "engines": { - "node": ">= 0.4" + "node": ">=8.3.0" } }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", + "node_modules/@stoplight/json-ref-readers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@stoplight/json-ref-readers/-/json-ref-readers-1.2.2.tgz", + "integrity": "sha512-nty0tHUq2f1IKuFYsLM4CXLZGHdMn+X/IwEUIpeSOXt0QjMUbL0Em57iJUDzz+2MkWG83smIigNZ3fauGjqgdQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "es-errors": "^1.3.0" + "node-fetch": "^2.6.0", + "tslib": "^1.14.1" }, "engines": { - "node": ">= 0.4" + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/json-ref-readers/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@stoplight/json-ref-resolver": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@stoplight/json-ref-resolver/-/json-ref-resolver-3.1.6.tgz", + "integrity": "sha512-YNcWv3R3n3U6iQYBsFOiWSuRGE5su1tJSiX6pAPRVk7dP0L7lqCteXGzuVRQ0gMZqUl8v1P0+fAKxF6PLo9B5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.21.0", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^12.3.0 || ^13.0.0", + "@types/urijs": "^1.19.19", + "dependency-graph": "~0.11.0", + "fast-memoize": "^2.5.2", + "immer": "^9.0.6", + "lodash": "^4.17.21", + "tslib": "^2.6.0", + "urijs": "^1.19.11" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/ordered-object-literal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/ordered-object-literal/-/ordered-object-literal-1.0.5.tgz", + "integrity": "sha512-COTiuCU5bgMUtbIFBuyyh2/yVVzlr5Om0v5utQDgBCuQUOPgU1DwoffkTfg4UBQOvByi5foF4w4T+H9CoRe5wg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/path": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@stoplight/path/-/path-1.3.2.tgz", + "integrity": "sha512-lyIc6JUlUA8Ve5ELywPC8I2Sdnh1zc1zmbYgVarhXIp9YeAB0ReeqmGEOWNtlHkbP2DAA1AL65Wfn2ncjK/jtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/spectral-cli": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-cli/-/spectral-cli-6.15.0.tgz", + "integrity": "sha512-FVeQIuqQQnnLfa8vy+oatTKUve7uU+3SaaAfdjpX/B+uB1NcfkKRJYhKT9wMEehDRaMPL5AKIRYMCFerdEbIpw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "~3.21.0", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-core": "^1.19.5", + "@stoplight/spectral-formatters": "^1.4.1", + "@stoplight/spectral-parsers": "^1.0.4", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-ruleset-bundler": "^1.6.0", + "@stoplight/spectral-ruleset-migrator": "^1.11.0", + "@stoplight/spectral-rulesets": ">=1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "chalk": "4.1.2", + "fast-glob": "~3.2.12", + "hpagent": "~1.2.0", + "lodash": "~4.17.21", + "pony-cause": "^1.1.1", + "stacktracey": "^2.1.8", + "tslib": "^2.8.1", + "yargs": "~17.7.2" + }, + "bin": { + "spectral": "dist/index.js" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-core": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-core/-/spectral-core-1.20.0.tgz", + "integrity": "sha512-5hBP81nCC1zn1hJXL/uxPNRKNcB+/pEIHgCjPRpl/w/qy9yC9ver04tw1W0l/PMiv0UeB5dYgozXVQ4j5a6QQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "~3.21.0", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-parsers": "^1.0.0", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "~13.6.0", + "@types/es-aggregate-error": "^1.0.2", + "@types/json-schema": "^7.0.11", + "ajv": "^8.17.1", + "ajv-errors": "~3.0.0", + "ajv-formats": "~2.1.1", + "es-aggregate-error": "^1.0.7", + "jsonpath-plus": "^10.3.0", + "lodash": "~4.17.21", + "lodash.topath": "^4.5.2", + "minimatch": "3.1.2", + "nimma": "0.2.3", + "pony-cause": "^1.1.1", + "simple-eval": "1.0.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/@stoplight/types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.6.0.tgz", + "integrity": "sha512-dzyuzvUjv3m1wmhPfq82lCVYGcXG0xUYgqnWfCq3PCVR4BKFhjdkHrnJ+jIDoMKvXb05AZP/ObQF6+NpDo29IQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/spectral-formats": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-formats/-/spectral-formats-1.8.2.tgz", + "integrity": "sha512-c06HB+rOKfe7tuxg0IdKDEA5XnjL2vrn/m/OVIIxtINtBzphZrOgtRn7epQ5bQF5SWp84Ue7UJWaGgDwVngMFw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.17.0", + "@stoplight/spectral-core": "^1.19.2", + "@types/json-schema": "^7.0.7", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-formatters": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-formatters/-/spectral-formatters-1.5.0.tgz", + "integrity": "sha512-lR7s41Z00Mf8TdXBBZQ3oi2uR8wqAtR6NO0KA8Ltk4FSpmAy0i6CKUmJG9hZQjanTnGmwpQkT/WP66p1GY3iXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/path": "^1.3.2", + "@stoplight/spectral-core": "^1.19.4", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.15.0", + "@types/markdown-escape": "^1.1.3", + "chalk": "4.1.2", + "cliui": "7.0.4", + "lodash": "^4.17.21", + "markdown-escape": "^2.0.0", + "node-sarif-builder": "^2.0.3", + "strip-ansi": "6.0", + "text-table": "^0.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-functions": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-functions/-/spectral-functions-1.10.1.tgz", + "integrity": "sha512-obu8ZfoHxELOapfGsCJixKZXZcffjg+lSoNuttpmUFuDzVLT3VmH8QkPXfOGOL5Pz80BR35ClNAToDkdnYIURg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "^3.17.1", + "@stoplight/spectral-core": "^1.19.4", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-runtime": "^1.1.2", + "ajv": "^8.17.1", + "ajv-draft-04": "~1.0.0", + "ajv-errors": "~3.0.0", + "ajv-formats": "~2.1.1", + "lodash": "~4.17.21", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-parsers": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-parsers/-/spectral-parsers-1.0.5.tgz", + "integrity": "sha512-ANDTp2IHWGvsQDAY85/jQi9ZrF4mRrA5bciNHX+PUxPr4DwS6iv4h+FVWJMVwcEYdpyoIdyL+SRmHdJfQEPmwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "~3.21.0", + "@stoplight/types": "^14.1.1", + "@stoplight/yaml": "~4.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-parsers/node_modules/@stoplight/types": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-14.1.1.tgz", + "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/spectral-ref-resolver": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ref-resolver/-/spectral-ref-resolver-1.0.5.tgz", + "integrity": "sha512-gj3TieX5a9zMW29z3mBlAtDOCgN3GEc1VgZnCVlr5irmR4Qi5LuECuFItAq4pTn5Zu+sW5bqutsCH7D4PkpyAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json-ref-readers": "1.2.2", + "@stoplight/json-ref-resolver": "~3.1.6", + "@stoplight/spectral-runtime": "^1.1.2", + "dependency-graph": "0.11.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-ruleset-bundler": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-bundler/-/spectral-ruleset-bundler-1.6.3.tgz", + "integrity": "sha512-AQFRO6OCKg8SZJUupnr3+OzI1LrMieDTEUHsYgmaRpNiDRPvzImE3bzM1KyQg99q58kTQyZ8kpr7sG8Lp94RRA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@rollup/plugin-commonjs": "~22.0.2", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-core": ">=1", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-functions": ">=1", + "@stoplight/spectral-parsers": ">=1", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-ruleset-migrator": "^1.9.6", + "@stoplight/spectral-rulesets": ">=1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "@types/node": "*", + "pony-cause": "1.1.1", + "rollup": "~2.79.2", + "tslib": "^2.8.1", + "validate-npm-package-name": "3.0.0" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-ruleset-migrator": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-migrator/-/spectral-ruleset-migrator-1.11.3.tgz", + "integrity": "sha512-+9Y1zFxYmSsneT5FPkgS1IlRQs0VgtdMT77f5xf6vzje9ezyhfs7oXwbZOCSZjEJew8iVZBKQtiOFndcBrdtqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "~3.21.0", + "@stoplight/ordered-object-literal": "~1.0.4", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-functions": "^1.9.1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "@stoplight/yaml": "~4.2.3", + "@types/node": "*", + "ajv": "^8.17.1", + "ast-types": "0.14.2", + "astring": "^1.9.0", + "reserved": "0.1.2", + "tslib": "^2.8.1", + "validate-npm-package-name": "3.0.0" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/yaml": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.2.3.tgz", + "integrity": "sha512-Mx01wjRAR9C7yLMUyYFTfbUf5DimEpHMkRDQ1PKLe9dfNILbgdxyrncsOXM3vCpsQ1Hfj4bPiGl+u4u6e9Akqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.1", + "@stoplight/types": "^13.0.0", + "@stoplight/yaml-ast-parser": "0.0.48", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=10.8" + } + }, + "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/yaml-ast-parser": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.48.tgz", + "integrity": "sha512-sV+51I7WYnLJnKPn2EMWgS4EUfoP4iWEbrWwbXsj0MZCB/xOK8j6+C9fntIdOM50kpx45ZLC3s6kwKivWuqvyg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@stoplight/spectral-rulesets": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-rulesets/-/spectral-rulesets-1.22.0.tgz", + "integrity": "sha512-l2EY2jiKKLsvnPfGy+pXC0LeGsbJzcQP5G/AojHgf+cwN//VYxW1Wvv4WKFx/CLmLxc42mJYF2juwWofjWYNIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@asyncapi/specs": "^6.8.0", + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "^3.17.0", + "@stoplight/spectral-core": "^1.19.4", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-functions": "^1.9.1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "@types/json-schema": "^7.0.7", + "ajv": "^8.17.1", + "ajv-formats": "~2.1.1", + "json-schema-traverse": "^1.0.0", + "leven": "3.1.0", + "lodash": "~4.17.21", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-runtime/-/spectral-runtime-1.1.4.tgz", + "integrity": "sha512-YHbhX3dqW0do6DhiPSgSGQzr6yQLlWybhKwWx0cqxjMwxej3TqLv3BXMfIUYFKKUqIwH4Q2mV8rrMM8qD2N0rQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.20.1", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^13.6.0", + "abort-controller": "^3.0.0", + "lodash": "^4.17.21", + "node-fetch": "^2.7.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/types": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", + "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.3.0.tgz", + "integrity": "sha512-JZlVFE6/dYpP9tQmV0/ADfn32L9uFarHWxfcRhReKUnljz1ZiUM5zpX+PH8h5CJs6lao3TuFqnPm9IJJCEkE2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.5", + "@stoplight/types": "^14.1.1", + "@stoplight/yaml-ast-parser": "0.0.50", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=10.8" + } + }, + "node_modules/@stoplight/yaml-ast-parser": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.50.tgz", + "integrity": "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@stoplight/yaml/node_modules/@stoplight/types": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-14.1.1.tgz", + "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/command-line-args": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", + "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", + "license": "MIT" + }, + "node_modules/@types/command-line-usage": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", + "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", + "license": "MIT" + }, + "node_modules/@types/es-aggregate-error": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/es-aggregate-error/-/es-aggregate-error-1.0.6.tgz", + "integrity": "sha512-qJ7LIFp06h1QE1aVxbVd+zJP2wdaugYXYfd6JxsyRMrYHaxb6itXPogW2tz+ylUJ1n1b+JF1PHyYCfYHm0dvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-escape": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/markdown-escape/-/markdown-escape-1.1.3.tgz", + "integrity": "sha512-JIc1+s3y5ujKnt/+N+wq6s/QdL2qZ11fP79MijrVXsAAnzSxCbT2j/3prHRouJdZ2yFLN3vkP0HytfnoCczjOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/urijs": { + "version": "1.19.26", + "resolved": "https://registry.npmjs.org/@types/urijs/-/urijs-1.19.26.tgz", + "integrity": "sha512-wkXrVzX5yoqLnndOwFsieJA7oKM8cNkOKJtf/3vVGSUFkWDKZvFHpIl9Pvqb/T9UsawBBFMTTD8xu7sK5MWuvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.1" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/apache-arrow": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-15.0.2.tgz", + "integrity": "sha512-RvwlFxLRpO405PLGffx4N2PYLiF7FD86Q1hHl6J2XCWiq+tTCzpb9ngFw0apFDcXZBMpCzMuwAvA7hjyL1/73A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.2", + "@types/command-line-args": "^5.2.1", + "@types/command-line-usage": "^5.0.2", + "@types/node": "^20.6.0", + "command-line-args": "^5.2.1", + "command-line-usage": "^7.0.1", + "flatbuffers": "^23.5.26", + "json-bignum": "^0.0.3", + "tslib": "^2.6.2" + }, + "bin": { + "arrow2csv": "bin/arrow2csv.cjs" + } + }, + "node_modules/apache-arrow/node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/apache-arrow/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/ast-types": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.14.2.tgz", + "integrity": "sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "dev": true, + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/builtins": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", + "integrity": "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk/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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk/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==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "license": "MIT", + "dependencies": { + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/command-line-usage": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz", + "integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.0", + "typical": "^7.1.1" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/command-line-usage/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/command-line-usage/node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-aggregate-error": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/es-aggregate-error/-/es-aggregate-error-1.0.14.tgz", + "integrity": "sha512-3YxX6rVb07B5TV11AV5wsL7nQCHXNwoHPsQC8S4AmBiqYhyNCJ5BRKXkXyDJvs8QzXN20NgRtxe3dEEQD9NLHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "globalthis": "^1.0.4", + "has-property-descriptors": "^1.0.2", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/escape-html": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-memoize": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", + "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "license": "MIT", + "dependencies": { + "array-back": "^3.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/flatbuffers": { + "version": "23.5.26", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-23.5.26.tgz", + "integrity": "sha512-vE+SI9vrJDwi1oETtTIFldC/o9GsVKRM+s6EL0nQgxXlYV1Vc4Tk30hj4xGICftInKQKj1F3up2n8UbIVobISQ==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/grpc-web": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/grpc-web/-/grpc-web-1.5.0.tgz", + "integrity": "sha512-y1tS3BBIoiVSzKTDF3Hm7E8hV2n7YY7pO0Uo7depfWJqKzWE+SKr0jvHNIJsJJYILQlpYShpi/DRJJMbosgDMQ==", + "license": "Apache-2.0" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/json-bignum": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", + "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonc-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.2.1.tgz", + "integrity": "sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpath-plus": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", + "integrity": "sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/lodash.topath": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.topath/-/lodash.topath-4.5.2.tgz", + "integrity": "sha512-1/W4dM+35DwvE/iEd1M9ekewOSTlpFekhw9mhAtrwjVqUr83/ilQiyAvmg4tVX7Unkcfl1KC+i9WdaT4B6aQcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/markdown-escape": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-escape/-/markdown-escape-2.0.0.tgz", + "integrity": "sha512-Trz4v0+XWlwy68LJIyw3bLbsJiC8XAbRCKF9DbEtZjyndKOGVx6n+wNB0VfoRmY2LKboQLeniap3xrb6LGSJ8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nimma": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/nimma/-/nimma-0.2.3.tgz", + "integrity": "sha512-1ZOI8J+1PKKGceo/5CT5GfQOG6H8I2BencSK06YarZ2wXwH37BSSUWldqJmMJYA5JfqDqffxDXynt6f11AyKcA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsep-plugin/regex": "^1.0.1", + "@jsep-plugin/ternary": "^1.0.2", + "astring": "^1.8.1", + "jsep": "^1.2.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + }, + "optionalDependencies": { + "jsonpath-plus": "^6.0.1 || ^10.1.0", + "lodash.topath": "^4.5.2" + } + }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-sarif-builder": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-2.0.3.tgz", + "integrity": "sha512-Pzr3rol8fvhG/oJjIq2NTVB0vmdNNlz22FENhhPojYRZ4/ee08CfK4YuKmuL54V9MLhI1kpzxfOJ/63LzmZzDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.4", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/nodemon": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pony-cause": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", + "integrity": "sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g==", + "dev": true, + "license": "0BSD", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reserved": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/reserved/-/reserved-0.1.2.tgz", + "integrity": "sha512-/qO54MWj5L8WCBP9/UNe2iefJc+L9yETbH32xO/ft/EYPOTCR5k+azvDUgdCOKwZH8hXwPd0b8XBL78Nn2U69g==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-stable-stringify": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-1.1.1.tgz", + "integrity": "sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==", + "dev": true, "license": "MIT" }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">= 0.6" + "node": ">=10" } }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", + "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "~1.3.1", "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "statuses": "~2.0.2" }, "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8.0" } }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "dependencies": { - "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", - "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" + "send": "~0.19.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.8.0" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, "engines": { - "node": ">= 0.6" + "node": ">= 0.4" } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, "engines": { - "node": ">= 0.6" + "node": ">= 0.4" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-eval": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-eval/-/simple-eval-1.0.1.tgz", + "integrity": "sha512-LH7FpTAkeD+y5xQC4fzS+tFtaNlvt3Ib1zKzvhjv/Y+cioV4zIuw4IZr2yhRLu67CWL7FR9/6KXKnjRoZTvGGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsep": "^1.3.6" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz", + "integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">= 0.8" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "internal-slot": "^1.1.0" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=8" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, "engines": { "node": ">= 0.4" }, @@ -430,11 +4439,18 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -442,388 +4458,443 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=8" } }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, "engines": { "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, "engines": { - "node": ">= 0.10" + "node": ">=4" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" + "node_modules/swagger-ui-dist": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", + "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" } }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=12.17" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "node_modules/table-layout/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", "license": "MIT", - "bin": { - "mime": "cli.js" - }, "engines": { - "node": ">=4" + "node": ">=12.17" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" }, "engines": { - "node": ">= 0.6" + "node": ">=6" } }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=8.0" } }, - "node_modules/neo4j-driver": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/neo4j-driver/-/neo4j-driver-6.0.1.tgz", - "integrity": "sha512-8DDF2MwEJNz7y7cp97x4u8fmVIP4CWS8qNBxdwxTG0fWtsS+2NdeC+7uXwmmuFOpHvkfXqv63uWY73bfDtOH8Q==", - "license": "Apache-2.0", - "dependencies": { - "neo4j-driver-bolt-connection": "6.0.1", - "neo4j-driver-core": "6.0.1", - "rxjs": "^7.8.2" - }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=0.6" } }, - "node_modules/neo4j-driver-bolt-connection": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-6.0.1.tgz", - "integrity": "sha512-1KyG73TO+CwnYJisdHD0sjUw9yR+P5q3JFcmVPzsHT4/whzCjuXSMpmY4jZcHH2PdY2cBUq4l/6WcDiPMxW2UA==", + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", "dependencies": { - "buffer": "^6.0.3", - "neo4j-driver-core": "6.0.1", - "string_decoder": "^1.3.0" + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" } }, - "node_modules/neo4j-driver-core": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/neo4j-driver-core/-/neo4j-driver-core-6.0.1.tgz", - "integrity": "sha512-5I2KxICAvcHxnWdJyDqwu8PBAQvWVTlQH2ve3VQmtVdJScPqWhpXN1PiX5IIl+cRF3pFpz9GQF53B5n6s0QQUQ==", - "license": "Apache-2.0" - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">= 0.6" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, "license": "MIT", "dependencies": { - "ee-first": "1.1.1" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.4" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, "engines": { - "node": ">= 0.8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, "license": "MIT", "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" }, "engines": { - "node": ">= 0.10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", "dependencies": { - "side-channel": "^1.1.0" + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" }, "engines": { - "node": ">=0.6" + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "node_modules/typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=8" } }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, "license": "MIT" }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, "engines": { - "node": ">= 0.8.0" + "node": ">= 10.0.0" } }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "dev": true, "license": "MIT" }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", + "integrity": "sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==", + "dev": true, + "license": "ISC", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, + "builtins": "^1.0.3" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", "engines": { - "node": ">= 0.8.0" + "node": ">= 0.8" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -832,14 +4903,26 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -848,16 +4931,17 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -866,17 +4950,20 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -885,77 +4972,86 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "node_modules/wordwrapjs": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=12.17" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, "engines": { - "node": ">=0.6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", "engines": { - "node": ">= 0.6" + "node": ">=10" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, "engines": { - "node": ">= 0.8" + "node": ">=12" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", "engines": { - "node": ">= 0.4.0" + "node": ">=12" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", + "node_modules/yargs/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, "engines": { - "node": ">= 0.8" + "node": ">=12" } } } diff --git a/package.json b/package.json index 028c662..aa74545 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,46 @@ { - "name": "what-if-simulation-engine", + "name": "predictive-analysis-engine", "version": "1.0.0", - "description": "What-if simulation engine for microservice call graphs", + "description": "Predictive analysis engine for microservice call graphs", "main": "index.js", "type": "commonjs", + "bin": { + "predict": "./bin/predict.js" + }, "scripts": { "start": "node index.js", + "dev": "nodemon index.js", "test": "node --test test/*.test.js", - "verify": "node verify-schema.js" + "openapi:validate": "spectral lint openapi.yaml", + "openapi:lint": "spectral lint openapi.yaml --format stylish" }, "repository": { "type": "git", - "url": "git+https://gitlab.com/team-alpha-zero/research-adaptive-micro-service-management/what-if-simulation-engine.git" + "url": "git+https://gitlab.com/team-alpha-zero/research-adaptive-micro-service-management/predictive-analysis-engine.git" }, "keywords": [ "microservices", "simulation", - "neo4j", + "graph-engine", "observability" ], "author": "", "license": "ISC", "bugs": { - "url": "https://gitlab.com/team-alpha-zero/research-adaptive-micro-service-management/what-if-simulation-engine/issues" + "url": "https://gitlab.com/team-alpha-zero/research-adaptive-micro-service-management/predictive-analysis-engine/issues" }, - "homepage": "https://gitlab.com/team-alpha-zero/research-adaptive-micro-service-management/what-if-simulation-engine#readme", + "homepage": "https://gitlab.com/team-alpha-zero/research-adaptive-micro-service-management/predictive-analysis-engine#readme", "dependencies": { + "@influxdata/influxdb3-client": "^0.7.0", + "better-sqlite3": "^11.8.1", + "commander": "^11.1.0", "dotenv": "^17.2.3", - "express": "^4.22.1", - "neo4j-driver": "^6.0.1" + "express": "^4.22.1" + }, + "devDependencies": { + "@stoplight/spectral-cli": "^6.15.0", + "js-yaml": "^4.1.0", + "nodemon": "^3.1.11", + "swagger-ui-express": "^5.0.1" } } diff --git a/src/clients/graphEngineClient.js b/src/clients/graphEngineClient.js new file mode 100644 index 0000000..ad61c9e --- /dev/null +++ b/src/clients/graphEngineClient.js @@ -0,0 +1,385 @@ +/** + * HTTP client for service-graph-engine API + * + * Uses native http/https modules to avoid external dependencies. + * Returns { ok: true, data } on success or { ok: false, error, status? } on failure. + * + * CONTAINER-LEVEL METRICS: + * As of the latest update, the /services endpoint now includes pod-level container metrics: + * - ramUsedMB: Pod RAM usage in MB (aggregated from all containers) + * - cpuUsagePercent: Pod CPU usage as percentage of node's total cores + * These metrics are available in the placement.nodes[].pods[] array. + */ + +const http = require('node:http'); +const https = require('node:https'); +const config = require('../config/config'); + +/** + * @typedef {Object} GraphHealthResponse + * @property {string} status - Health status ("OK") + * @property {number} lastUpdatedSecondsAgo - Seconds since last graph update + * @property {number} windowMinutes - Aggregation window in minutes + * @property {boolean} stale - Whether the graph data is stale + */ + +/** + * @typedef {Object} PodInfo + * @property {string} name - Pod name + * @property {number} ramUsedMB - Pod RAM usage in MB + * @property {number} cpuUsagePercent - Pod CPU usage as percentage of node's total cores + * @property {number} uptimeSeconds - Pod uptime in seconds + */ + +/** + * @typedef {Object} NodeResources + * @property {Object} cpu - CPU metrics + * @property {number} cpu.usagePercent - Node CPU usage percentage + * @property {number} cpu.cores - Total CPU cores on node + * @property {Object} ram - RAM metrics + * @property {number} ram.usedMB - RAM used on node in MB + * @property {number} ram.totalMB - Total RAM on node in MB + */ + +/** + * @typedef {Object} NodePlacement + * @property {string} node - Node name + * @property {NodeResources} resources - Node resource usage + * @property {Array} pods - Pods running on this node + */ + +/** + * @typedef {Object} ServicePlacement + * @property {Array} nodes - Nodes hosting this service's pods + */ + +/** + * @typedef {Object} ServiceInfo + * @property {string} name - Service name + * @property {string} namespace - Kubernetes namespace + * @property {number} podCount - Number of pods running + * @property {number} availability - Availability score (0-1) + * @property {ServicePlacement} placement - Pod placement with container-level metrics + */ + +/** + * @typedef {Object} ServicesResponse + * @property {Array} services - List of services + */ + +/** + * @typedef {Object} EdgeMetrics + * @property {number} rate - Request rate (requests per second) + * @property {number} p50 - 50th percentile latency (ms) + * @property {number} p95 - 95th percentile latency (ms) + * @property {number} p99 - 99th percentile latency (ms) + * @property {number} errorRate - Error rate (0-1) + */ + +/** + * @typedef {Object} Edge + * @property {string} from - Source service name + * @property {string} to - Target service name + * @property {number} rate - Request rate + * @property {number} errorRate - Error rate + * @property {number} p50 - 50th percentile latency + * @property {number} p95 - 95th percentile latency + * @property {number} p99 - 99th percentile latency + */ + +/** + * @typedef {Object} Node + * @property {string} name - Service name + * @property {string} namespace - Kubernetes namespace + * @property {number} podCount - Number of pods + * @property {number} availability - Availability score (0-1) + */ + +/** + * @typedef {Object} NeighborhoodResponse + * @property {string} center - Center service name + * @property {number} k - Number of hops + * @property {Array} nodes - List of nodes in neighborhood + * @property {Array} edges - List of edges in neighborhood + */ + +/** + * @typedef {Object} PeerMetrics + * @property {number} rate - Request rate + * @property {number} p50 - 50th percentile latency + * @property {number} p95 - 95th percentile latency + * @property {number} p99 - 99th percentile latency + * @property {number} errorRate - Error rate + */ + +/** + * @typedef {Object} Peer + * @property {string} service - Peer service name + * @property {number} podCount - Number of pods + * @property {number} availability - Availability score + * @property {PeerMetrics} metrics - Edge metrics + */ + +/** + * @typedef {Object} PeersResponse + * @property {string} service - Service name + * @property {string} direction - Direction ('in' or 'out') + * @property {number} windowMinutes - Aggregation window in minutes + * @property {Array} peers - List of peer services + */ + +/** + * @typedef {Object} CentralityScore + * @property {string} service - Service name + * @property {number} value - Centrality score value + */ + +/** + * @typedef {Object} CentralityTopResponse + * @property {string} metric - The centrality metric used (pagerank/betweenness) + * @property {Array} top - Top services by centrality + */ + +/** + * @typedef {Object} ServiceScore + * @property {string} service - Service name + * @property {number} pagerank - PageRank centrality score + * @property {number} betweenness - Betweenness centrality score + */ + +/** + * @typedef {Object} CentralityScoresResponse + * @property {number} windowMinutes - Aggregation window in minutes + * @property {Array} scores - List of service centrality scores + */ + +/** + * @typedef {Object} ServiceMetrics + * @property {string} name - Service name + * @property {string} namespace - Kubernetes namespace + * @property {number} rps - Requests per second + * @property {number} errorRate - Error rate + * @property {number} p95 - 95th percentile latency + */ + +/** + * @typedef {Object} EdgeSnapshot + * @property {string} from - Source service + * @property {string} to - Target service + * @property {string} namespace - Kubernetes namespace + * @property {number} rps - Requests per second + * @property {number} errorRate - Error rate + * @property {number} p95 - 95th percentile latency + */ + +/** + * @typedef {Object} MetricsSnapshotResponse + * @property {string} timestamp - ISO timestamp + * @property {string} window - Time window (e.g., '1m') + * @property {Array} services - Service metrics + * @property {Array} edges - Edge metrics + */ + +/** + * @typedef {Object} ClientSuccess + * @property {true} ok + * @property {*} data - Parsed JSON response + */ + +/** + * @typedef {Object} ClientError + * @property {false} ok + * @property {string} error - Error message + * @property {number} [status] - HTTP status code (if applicable) + */ + +/** + * Make an HTTP GET request with timeout + * @param {string} url - Full URL to request + * @param {number} timeoutMs - Request timeout in milliseconds + * @returns {Promise} + */ +function httpGet(url, timeoutMs) { + return new Promise((resolve) => { + const parsedUrl = new URL(url); + const transport = parsedUrl.protocol === 'https:' ? https : http; + + const req = transport.get(url, { timeout: timeoutMs }, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + let parsed; + try { + parsed = JSON.parse(data); + } catch (parseError) { + // JSON parse failed - include parse error message + resolve({ + ok: false, + error: `Invalid JSON response: ${parseError.message}`, + status: res.statusCode + }); + return; + } + resolve({ ok: true, data: parsed }); + } else { + resolve({ ok: false, error: `HTTP ${res.statusCode}`, status: res.statusCode }); + } + }); + }); + + req.on('error', (err) => { + resolve({ ok: false, error: err.message }); + }); + + req.on('timeout', () => { + req.destroy(); + resolve({ ok: false, error: 'Request timeout' }); + }); + }); +} + +/** + * Normalize base URL by removing trailing slash + * @param {string} baseUrl + * @returns {string} + */ +function normalizeBaseUrl(baseUrl) { + return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; +} + +/** + * Check health of the service-graph-engine + * @returns {Promise} + */ +async function checkGraphHealth() { + const baseUrl = normalizeBaseUrl(config.graphApi.baseUrl); + const url = `${baseUrl}/graph/health`; + return httpGet(url, config.graphApi.timeoutMs); +} + +/** + * Get the configured base URL (for testing/debugging) + * @returns {string|undefined} + */ +function getBaseUrl() { + return config.graphApi.baseUrl; +} + +/** + * Check if graph API is enabled (always true - Graph Engine is the only data source) + * @returns {boolean} + */ +function isEnabled() { + return true; +} + +/** + * Get k-hop neighborhood for a service +/** + * Get k-hop neighborhood for a service + * @param {string} serviceName - Service name (e.g., "frontend") + * @param {number} k - Number of hops + * @returns {Promise} + */ +async function getNeighborhood(serviceName, k) { + const baseUrl = normalizeBaseUrl(config.graphApi.baseUrl); + const url = `${baseUrl}/services/${encodeURIComponent(serviceName)}/neighborhood?k=${k}`; + return httpGet(url, config.graphApi.timeoutMs); +} + +/** + * Get peers (callers or callees) for a service + * @param {string} serviceName - Service name (e.g., "frontend") + * @param {string} direction - 'in' for callers, 'out' for callees + * @returns {Promise} + */ +async function getPeers(serviceName, direction) { + const baseUrl = normalizeBaseUrl(config.graphApi.baseUrl); + const url = `${baseUrl}/services/${encodeURIComponent(serviceName)}/peers?direction=${direction}`; + return httpGet(url, config.graphApi.timeoutMs); +} + +/** + * Get top services by centrality metric + * @param {string} [metric='pagerank'] - Centrality metric (pagerank, betweenness) + * @param {number} [limit=5] - Number of top services to return + * @returns {Promise} + */ +async function getCentralityTop(metric = 'pagerank', limit = 5) { + // Validate metric to prevent injection + const validMetrics = ['pagerank', 'betweenness']; + if (!validMetrics.includes(metric)) { + return { ok: false, error: `Invalid metric: ${metric}. Allowed: ${validMetrics.join(', ')}` }; + } + + const baseUrl = normalizeBaseUrl(config.graphApi.baseUrl); + const url = `${baseUrl}/centrality/top?metric=${metric}&limit=${limit}`; + return httpGet(url, config.graphApi.timeoutMs); +} + +/** + * List all services from the graph (basic info only) + * Returns {services: [{name, namespace, podCount, availability}, ...]} + * @returns {Promise} + */ +async function getServices() { + const baseUrl = normalizeBaseUrl(config.graphApi.baseUrl); + const url = `${baseUrl}/services`; + return httpGet(url, config.graphApi.timeoutMs); +} + +/** + * List all services with pod-level placement and resource metrics + * Returns {services: [{name, namespace, podCount, availability, placement: {nodes: [...]}}, ...]} + * Placement includes node-level CPU/RAM metrics and pod-level container metrics (ramUsedMB, cpuUsagePercent) + * Note: This calls the same endpoint as getServices() - the Graph Engine always returns placement data + * @returns {Promise} + */ +async function getServicesWithPlacement() { + // Graph Engine's /services endpoint always includes placement data when available + // This is a semantic wrapper for clarity in the codebase + return getServices(); +} + +/** + * Get metrics snapshot (all services and edges in one call) + * Returns {timestamp, window, services: [...], edges: [...]} + * @returns {Promise} + */ +async function getMetricsSnapshot() { + const baseUrl = normalizeBaseUrl(config.graphApi.baseUrl); + const url = `${baseUrl}/metrics/snapshot`; + return httpGet(url, config.graphApi.timeoutMs); +} + +/** + * Get centrality scores for all services (PageRank and Betweenness) + * Returns {windowMinutes, scores: [{service, pagerank, betweenness}, ...]} + * @returns {Promise} + */ +async function getCentralityScores() { + const baseUrl = normalizeBaseUrl(config.graphApi.baseUrl); + const url = `${baseUrl}/centrality`; + return httpGet(url, config.graphApi.timeoutMs); +} + +module.exports = { + checkGraphHealth, + getNeighborhood, + getPeers, + getCentralityTop, + getCentralityScores, + getServices, + getServicesWithPlacement, + getMetricsSnapshot, + getBaseUrl, + isEnabled, + // Exported for testing + _httpGet: httpGet, + _normalizeBaseUrl: normalizeBaseUrl +}; diff --git a/src/clients/influxWriter.js b/src/clients/influxWriter.js new file mode 100644 index 0000000..ef083e6 --- /dev/null +++ b/src/clients/influxWriter.js @@ -0,0 +1,244 @@ +/** + * InfluxDB 3 Writer + * Writes service and edge metrics to InfluxDB using line protocol + */ + +const { InfluxDBClient } = require('@influxdata/influxdb3-client'); +const config = require('../config/config'); + +class InfluxWriter { + constructor() { + this.client = null; + this.database = config.influx.database; + + if (config.influx.host && config.influx.token && config.influx.database) { + try { + this.client = new InfluxDBClient({ + host: config.influx.host, + token: config.influx.token, + database: config.influx.database + }); + // Note: InfluxDB 3 client uses nanosecond precision by default + // Timestamps in line protocol are automatically handled + console.log(`[InfluxDB] Writer initialized for database: ${this.database} (precision: nanoseconds)`); + } catch (error) { + console.error(`[InfluxDB] Failed to initialize client: ${error.message}`); + } + } else { + console.warn('[InfluxDB] Writer not configured (missing INFLUX_HOST, INFLUX_TOKEN, or INFLUX_DATABASE)'); + } + } + + /** + * Write service metrics to InfluxDB + * @param {Array} services - Array of service objects with metrics + */ + async writeServiceMetrics(services) { + if (!this.client) { + console.warn('[InfluxDB] Client not configured, skipping service metrics write'); + return; + } + + if (!services || services.length === 0) { + return; + } + + try { + const lines = services.map(svc => { + const tags = `service=${this.escapeTag(svc.name)},namespace=${this.escapeTag(svc.namespace || 'default')}`; + + // Build fields array, filtering out null values + const fieldPairs = [ + { key: 'request_rate', value: this.formatNumber(svc.requestRate) }, + { key: 'error_rate', value: this.formatNumber(svc.errorRate) }, + { key: 'p50', value: this.formatNumber(svc.p50) }, + { key: 'p95', value: this.formatNumber(svc.p95) }, + { key: 'p99', value: this.formatNumber(svc.p99) }, + { key: 'availability', value: this.formatNumber(svc.availability) } + ].filter(f => f.value !== null); + + // Skip if no valid fields + if (fieldPairs.length === 0) return null; + + const fields = fieldPairs.map(f => `${f.key}=${f.value}`).join(','); + return `service_metrics,${tags} ${fields}`; + }).filter(line => line !== null); + + if (lines.length === 0) { + console.log('[InfluxDB] No valid service metrics to write (all null)'); + return; + } + + await this.client.write(lines.join('\n'), this.database); + console.log(`[InfluxDB] Wrote ${services.length} service metrics`); + } catch (error) { + console.error(`[InfluxDB] Error writing service metrics: ${error.message}`); + } + } + + /** + * Write edge metrics to InfluxDB + * @param {Array} edges - Array of edge objects with metrics + */ + async writeEdgeMetrics(edges) { + if (!this.client) { + console.warn('[InfluxDB] Client not configured, skipping edge metrics write'); + return; + } + + if (!edges || edges.length === 0) { + return; + } + + try { + const lines = edges.map(edge => { + const tags = `from=${this.escapeTag(edge.from)},to=${this.escapeTag(edge.to)},namespace=${this.escapeTag(edge.namespace || 'default')}`; + + // Build fields array, filtering out null values + const fieldPairs = [ + { key: 'request_rate', value: this.formatNumber(edge.requestRate) }, + { key: 'error_rate', value: this.formatNumber(edge.errorRate) }, + { key: 'p50', value: this.formatNumber(edge.p50) }, + { key: 'p95', value: this.formatNumber(edge.p95) }, + { key: 'p99', value: this.formatNumber(edge.p99) } + ].filter(f => f.value !== null); + + // Skip if no valid fields + if (fieldPairs.length === 0) return null; + + const fields = fieldPairs.map(f => `${f.key}=${f.value}`).join(','); + return `edge_metrics,${tags} ${fields}`; + }).filter(line => line !== null); + + if (lines.length === 0) { + console.log('[InfluxDB] No valid edge metrics to write (all null)'); + return; + } + + await this.client.write(lines.join('\n'), this.database); + console.log(`[InfluxDB] Wrote ${edges.length} edge metrics`); + } catch (error) { + console.error(`[InfluxDB] Error writing edge metrics: ${error.message}`); + } + } + + /** + * Escape tag values for InfluxDB line protocol + */ + escapeTag(value) { + if (!value) return 'unknown'; + return String(value).replace(/[, =]/g, '\\$&'); + } + + /** + * Format number values, handling null/undefined + * Returns null for missing values (InfluxDB line protocol omits null fields) + * This ensures averages don't include zeros for missing data + */ + formatNumber(value) { + if (value === null || value === undefined || Number.isNaN(value)) { + return null; + } + return String(value); + } + + /** + * Write infrastructure metrics (nodes and pods) to InfluxDB + * @param {Object} data - { nodes: [], services: [] } + */ + async writeInfrastructureMetrics(data) { + if (!this.client) { + // console.warn('[InfluxDB] Client not configured, skipping infra metrics write'); + return; + } + + if (!data || !data.nodes || data.nodes.length === 0) { + return; + } + + try { + const dbLines = []; + + // 1. Process Node Metrics + data.nodes.forEach(node => { + const nodeName = node.node || node.name; // Handle potential schema variations + if (!nodeName) return; + + const tags = `node=${this.escapeTag(nodeName)}`; + + const resources = node.resources || {}; + const cpu = resources.cpu || {}; + const ram = resources.ram || {}; + + // Flat structure for fallback if resources object is different + // In pollWorker we might map it differently, but let's support the structure from Graph Engine + const cpuUsage = cpu.usagePercent ?? node.cpuUsagePercent; + const cpuCores = cpu.cores ?? node.cores; + const ramUsed = ram.usedMB ?? node.ramUsedMB; + const ramTotal = ram.totalMB ?? node.ramTotalMB; + + const fieldPairs = [ + { key: 'cpu_usage_percent', value: this.formatNumber(cpuUsage) }, + { key: 'cpu_total_cores', value: this.formatNumber(cpuCores) }, + { key: 'ram_used_mb', value: this.formatNumber(ramUsed) }, + { key: 'ram_total_mb', value: this.formatNumber(ramTotal) }, + { key: 'pod_count', value: this.formatNumber(node.pods ? node.pods.length : 0) } + ].filter(f => f.value !== null); + + if (fieldPairs.length > 0) { + const fields = fieldPairs.map(f => `${f.key}=${f.value}`).join(','); + dbLines.push(`node_metrics,${tags} ${fields}`); + } + + // 2. Process Pod Metrics (embedded in nodes) + if (node.pods && Array.isArray(node.pods)) { + node.pods.forEach(pod => { + if (!pod.name) return; + + // Extract namespace from pod name or other context if available + // Graph Engine structure might just have name. We'll try to guess or use default. + // Ideally should be passed down. For now, rely on pod name. + const podTags = `pod=${this.escapeTag(pod.name)},node=${this.escapeTag(nodeName)}`; + + const podFields = [ + { key: 'ram_used_mb', value: this.formatNumber(pod.ramUsedMB) }, + { key: 'cpu_usage_percent', value: this.formatNumber(pod.cpuUsagePercent) }, + { key: 'cpu_usage_cores', value: this.formatNumber(pod.cpuUsageCores) } + ].filter(f => f.value !== null); + + if (podFields.length > 0) { + const fields = podFields.map(f => `${f.key}=${f.value}`).join(','); + dbLines.push(`pod_metrics,${podTags} ${fields}`); + } + }); + } + }); + + if (dbLines.length === 0) { + return; + } + + await this.client.write(dbLines.join('\n'), this.database); + console.log(`[InfluxDB] Wrote ${dbLines.length} infrastructure metric points`); + + } catch (error) { + console.error(`[InfluxDB] Error writing infra metrics: ${error.message}`); + } + } + + /** + * Close the InfluxDB client + */ + async close() { + if (this.client) { + try { + await this.client.close(); + console.log('[InfluxDB] Writer closed'); + } catch (error) { + console.error(`[InfluxDB] Error closing writer: ${error.message}`); + } + } + } +} + +module.exports = InfluxWriter; diff --git a/src/config.js b/src/config.js deleted file mode 100644 index 8456ac1..0000000 --- a/src/config.js +++ /dev/null @@ -1,52 +0,0 @@ -require('dotenv').config(); - -/** - * @typedef {Object} Neo4jConfig - * @property {string} uri - Neo4j connection URI - * @property {string} user - Neo4j username - * @property {string} password - Neo4j password (never logged) - */ - -/** - * @typedef {Object} SimulationConfig - * @property {string} defaultLatencyMetric - Default latency metric (p50, p95, p99) - * @property {number} maxTraversalDepth - Maximum k-hop depth (validated to 1-3) - * @property {string} scalingModel - Scaling model type (bounded_sqrt, linear) - * @property {number} scalingAlpha - Fixed overhead fraction (0.0-1.0) - * @property {number} minLatencyFactor - Minimum latency improvement factor - * @property {number} timeoutMs - Neo4j query and HTTP request timeout - * @property {number} maxPathsReturned - Maximum paths to return in results - */ - -/** - * @typedef {Object} ServerConfig - * @property {number} port - HTTP server port - */ - -/** - * @typedef {Object} Config - * @property {Neo4jConfig} neo4j - * @property {SimulationConfig} simulation - * @property {ServerConfig} server - */ - -/** @type {Config} */ -module.exports = { - neo4j: { - uri: process.env.NEO4J_URI || 'neo4j+s://517b3e75.databases.neo4j.io', - user: process.env.NEO4J_USER || 'neo4j', - password: process.env.NEO4J_PASSWORD || 'Ex-hfrpIOCfghD-dZ04f2ya3-zbUpBdsZSgjwl6a8Rg' - }, - simulation: { - defaultLatencyMetric: process.env.DEFAULT_LATENCY_METRIC || 'p95', - maxTraversalDepth: parseInt(process.env.MAX_TRAVERSAL_DEPTH) || 2, - scalingModel: process.env.SCALING_MODEL || 'bounded_sqrt', - scalingAlpha: parseFloat(process.env.SCALING_ALPHA) || 0.5, - minLatencyFactor: parseFloat(process.env.MIN_LATENCY_FACTOR) || 0.6, - timeoutMs: parseInt(process.env.TIMEOUT_MS) || 8000, - maxPathsReturned: parseInt(process.env.MAX_PATHS_RETURNED) || 10 - }, - server: { - port: parseInt(process.env.PORT) || 3000 - } -}; diff --git a/src/config/config.js b/src/config/config.js new file mode 100644 index 0000000..6ae62b9 --- /dev/null +++ b/src/config/config.js @@ -0,0 +1,97 @@ +require('dotenv').config(); + +/** + * Validate required environment variables at startup. + * Fails fast with clear error messages before any connections are attempted. + * + * Call this explicitly from index.js before starting the server. + * Not auto-run on import to avoid breaking tests and utility scripts. + */ +function validateEnv() { + const errors = []; + + // Graph Engine is always required + if (!process.env.GRAPH_ENGINE_BASE_URL && !process.env.SERVICE_GRAPH_ENGINE_URL) { + errors.push('GRAPH_ENGINE_BASE_URL (or SERVICE_GRAPH_ENGINE_URL) is required'); + } + + if (errors.length > 0) { + console.error('\n❌ Missing required environment variables:\n'); + errors.forEach(err => console.error(` - ${err}`)); + console.error('\n Set GRAPH_ENGINE_BASE_URL or SERVICE_GRAPH_ENGINE_URL to point to service-graph-engine.\n'); + console.error(' Example: GRAPH_ENGINE_BASE_URL=http://service-graph-engine:3000\n'); + process.exit(1); + } +} + +/** + * @typedef {Object} SimulationConfig + * @property {string} defaultLatencyMetric - Default latency metric (p50, p95, p99) + * @property {number} maxTraversalDepth - Maximum k-hop depth (validated to 1-3) + * @property {string} scalingModel - Scaling model type (bounded_sqrt, linear) + * @property {number} scalingAlpha - Fixed overhead fraction (0.0-1.0) + * @property {number} minLatencyFactor - Minimum latency improvement factor + * @property {number} timeoutMs - HTTP request timeout + * @property {number} maxPathsReturned - Maximum paths to return in results + */ + +/** + * @typedef {Object} ServerConfig + * @property {number} port - HTTP server port + */ + +/** + * @typedef {Object} GraphApiConfig + * @property {string} baseUrl - Base URL of service-graph-engine + * @property {number} timeoutMs - Request timeout in milliseconds + * @property {boolean} required - Whether Graph API failure should degrade overall status + */ + +/** + * @typedef {Object} Config + * @property {SimulationConfig} simulation + * @property {ServerConfig} server + * @property {GraphApiConfig} graphApi + */ + +/** @type {Config} */ +const config = { + simulation: { + defaultLatencyMetric: process.env.DEFAULT_LATENCY_METRIC || 'p95', + maxTraversalDepth: parseInt(process.env.MAX_TRAVERSAL_DEPTH) || 2, + scalingModel: process.env.SCALING_MODEL || 'bounded_sqrt', + scalingAlpha: parseFloat(process.env.SCALING_ALPHA) || 0.5, + minLatencyFactor: parseFloat(process.env.MIN_LATENCY_FACTOR) || 0.6, + timeoutMs: parseInt(process.env.TIMEOUT_MS) || 8000, + maxPathsReturned: parseInt(process.env.MAX_PATHS_RETURNED) || 10 + }, + server: { + port: parseInt(process.env.PORT) || 5000 + }, + graphApi: { + baseUrl: process.env.GRAPH_ENGINE_BASE_URL || process.env.SERVICE_GRAPH_ENGINE_URL || 'http://service-graph-engine:3000', + timeoutMs: parseInt(process.env.GRAPH_API_TIMEOUT_MS) || 5000 + }, + rateLimit: { + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000, + maxRequests: parseInt(process.env.RATE_LIMIT_MAX) || 60 + }, + influx: { + host: process.env.INFLUX_HOST || '', + token: process.env.INFLUX_TOKEN || '', + database: process.env.INFLUX_DATABASE || '' + }, + sqlite: { + dbPath: process.env.SQLITE_DB_PATH || './data/decisions.db' + }, + telemetryWorker: { + enabled: process.env.TELEMETRY_WORKER_ENABLED !== 'false', + pollIntervalMs: parseInt(process.env.TELEMETRY_POLL_INTERVAL_MS) || 60000 + }, + telemetry: { + enabled: process.env.TELEMETRY_ENABLED !== 'false' + } +}; + +module.exports = config; +module.exports.validateEnv = validateEnv; diff --git a/src/failureSimulation.js b/src/failureSimulation.js deleted file mode 100644 index 3c4404b..0000000 --- a/src/failureSimulation.js +++ /dev/null @@ -1,101 +0,0 @@ -const { fetchUpstreamNeighborhood, findTopPathsToTarget } = require('./graph'); -const config = require('./config'); - -/** - * @typedef {import('./neo4j').EdgeData} EdgeData - * @typedef {import('./graph').GraphSnapshot} GraphSnapshot - */ - -/** - * @typedef {Object} FailureSimulationRequest - * @property {string} serviceId - Target service ID - * @property {number} [maxDepth] - Maximum traversal depth (default from config) - */ - -/** - * @typedef {Object} AffectedCaller - * @property {string} serviceId - Caller service ID - * @property {number} lostTrafficRps - Lost traffic in requests per second - * @property {number} edgeErrorRate - Error rate on removed edge - */ - -/** - * @typedef {Object} BrokenPath - * @property {string[]} path - Array of service IDs in path - * @property {number} pathRps - Path throughput (bottleneck rate) - */ - -/** - * @typedef {Object} FailureSimulationResult - * @property {Object} target - Target service info - * @property {number} depth - Traversal depth used - * @property {AffectedCaller[]} affectedCallers - Direct callers impacted - * @property {BrokenPath[]} criticalPathsBroken - Top N broken paths - */ - -/** - * Simulate failure of a service by removing it from the graph (in-memory only) - * Calculates traffic loss and identifies broken paths - * - * Algorithm: - * 1. Fetch k-hop upstream neighborhood - * 2. Remove target node (in-memory) - * 3. For each direct caller, compute lostTrafficRps (sum of edge rates) - * 4. Find top N paths that included target (sorted by pathRps) - * - * @param {FailureSimulationRequest} request - Simulation request - * @returns {Promise} - */ -async function simulateFailure(request) { - const maxDepth = request.maxDepth || config.simulation.maxTraversalDepth; - - // Validate depth - if (maxDepth < 1 || maxDepth > 3) { - throw new Error(`maxDepth must be 1, 2, or 3. Got: ${maxDepth}`); - } - - // Fetch upstream neighborhood (read-only Neo4j query) - const snapshot = await fetchUpstreamNeighborhood(request.serviceId, maxDepth); - - // Get target node info - const targetNode = snapshot.nodes.get(request.serviceId); - if (!targetNode) { - throw new Error(`Service not found: ${request.serviceId}`); - } - - // Find all direct callers of target - const directCallers = snapshot.incomingEdges.get(request.serviceId) || []; - - // Calculate lost traffic for each caller - const affectedCallers = directCallers.map(edge => ({ - serviceId: edge.source, - lostTrafficRps: edge.rate, - edgeErrorRate: edge.errorRate - })); - - // Sort by lost traffic descending - affectedCallers.sort((a, b) => b.lostTrafficRps - a.lostTrafficRps); - - // Find top N broken paths - const brokenPaths = findTopPathsToTarget( - snapshot, - request.serviceId, - maxDepth, - config.simulation.maxPathsReturned - ); - - return { - target: { - serviceId: targetNode.serviceId, - name: targetNode.name, - namespace: targetNode.namespace - }, - depth: maxDepth, - affectedCallers, - criticalPathsBroken: brokenPaths - }; -} - -module.exports = { - simulateFailure -}; diff --git a/src/graph.js b/src/graph.js deleted file mode 100644 index 10c167d..0000000 --- a/src/graph.js +++ /dev/null @@ -1,177 +0,0 @@ -const { executeQuery } = require('./neo4j'); -const config = require('./config'); - -/** - * @typedef {import('./neo4j').EdgeData} EdgeData - * @typedef {import('./neo4j').NodeData} NodeData - */ - -/** - * @typedef {Object} GraphSnapshot - * @property {Map} nodes - Map of serviceId to node data - * @property {EdgeData[]} edges - Array of all edges - * @property {Map} incomingEdges - Map of target serviceId to incoming edges - * @property {Map} outgoingEdges - Map of source serviceId to outgoing edges - */ - -/** - * Fetch k-hop upstream neighborhood (nodes that can reach target) - * Uses 2-query approach to avoid duplicates and path explosion - * - * @param {string} targetServiceId - Target service ID - * @param {number} maxDepth - Maximum traversal depth (validated to 1-3) - * @returns {Promise} - */ -async function fetchUpstreamNeighborhood(targetServiceId, maxDepth) { - // Validate depth (1-3 only, safe for string injection) - if (maxDepth < 1 || maxDepth > 3 || !Number.isInteger(maxDepth)) { - throw new Error(`Invalid maxDepth: ${maxDepth}. Must be 1, 2, or 3`); - } - - // Query A: Get all node IDs in upstream neighborhood - // String-inject depth (validated integer) to avoid parameterization issues - const nodeQuery = ` - MATCH (target:Service {serviceId: $targetId}) - OPTIONAL MATCH path = (upstream:Service)-[:CALLS_NOW*1..${maxDepth}]->(target) - WITH target, COLLECT(DISTINCT upstream) AS upstreams - UNWIND upstreams + [target] AS service - WITH DISTINCT service - WHERE service IS NOT NULL - RETURN service.serviceId AS serviceId, - service.name AS name, - service.namespace AS namespace - `; - - const nodeResult = await executeQuery(nodeQuery, { targetId: targetServiceId }); - - if (nodeResult.records.length === 0) { - throw new Error(`Service not found: ${targetServiceId}`); - } - - // Build node set - const nodes = new Map(); - const nodeIds = []; - - nodeResult.records.forEach(record => { - const serviceId = record.get('serviceId'); - const name = record.get('name'); - const namespace = record.get('namespace'); - - nodes.set(serviceId, { serviceId, name, namespace }); - nodeIds.push(serviceId); - }); - - // Query B: Fetch all edges among these nodes - const edgeQuery = ` - MATCH (a:Service)-[r:CALLS_NOW]->(b:Service) - WHERE a.serviceId IN $nodeIds AND b.serviceId IN $nodeIds - RETURN - a.serviceId AS source, - b.serviceId AS target, - r.rate AS rate, - r.errorRate AS errorRate, - r.p50 AS p50, - r.p95 AS p95, - r.p99 AS p99 - `; - - const edgeResult = await executeQuery(edgeQuery, { nodeIds }); - - // Build edge arrays and adjacency maps - const edges = []; - const incomingEdges = new Map(); - const outgoingEdges = new Map(); - - // Initialize adjacency maps for all nodes - nodeIds.forEach(id => { - incomingEdges.set(id, []); - outgoingEdges.set(id, []); - }); - - edgeResult.records.forEach(record => { - const edge = { - source: record.get('source'), - target: record.get('target'), - rate: record.get('rate') || 0, - errorRate: record.get('errorRate') || 0, - p50: record.get('p50') || 0, - p95: record.get('p95') || 0, - p99: record.get('p99') || 0 - }; - - edges.push(edge); - incomingEdges.get(edge.target).push(edge); - outgoingEdges.get(edge.source).push(edge); - }); - - return { - nodes, - edges, - incomingEdges, - outgoingEdges - }; -} - -/** - * Find top N paths by traffic volume (bottleneck throughput) - * Uses min(edge.rate) along path as proxy for path throughput - * Hard-capped to prevent combinatorial explosion - * - * @param {GraphSnapshot} snapshot - Graph snapshot - * @param {string} targetServiceId - Target service ID - * @param {number} maxDepth - Maximum path length - * @param {number} maxPaths - Maximum paths to return - * @returns {Array<{path: string[], pathRps: number}>} - */ -function findTopPathsToTarget(snapshot, targetServiceId, maxDepth, maxPaths = config.simulation.maxPathsReturned) { - const paths = []; - const visited = new Set(); - - // DFS to enumerate paths (limited by maxPaths hard cap) - function dfs(currentId, currentPath, minRate) { - if (paths.length >= maxPaths * 2) return; // Safety: early exit at 2x limit - - if (currentId === targetServiceId && currentPath.length > 1) { - paths.push({ - path: [...currentPath], - pathRps: minRate - }); - return; - } - - if (currentPath.length >= maxDepth) return; - - const outgoing = snapshot.outgoingEdges.get(currentId) || []; - for (const edge of outgoing) { - if (visited.has(edge.target)) continue; // Prevent cycles - - visited.add(edge.target); - currentPath.push(edge.target); - - const newMinRate = Math.min(minRate, edge.rate); - dfs(edge.target, currentPath, newMinRate); - - currentPath.pop(); - visited.delete(edge.target); - } - } - - // Start DFS from all nodes (except target) - for (const [nodeId, _] of snapshot.nodes) { - if (nodeId === targetServiceId) continue; - if (paths.length >= maxPaths * 2) break; - - visited.clear(); - visited.add(nodeId); - dfs(nodeId, [nodeId], Infinity); - } - - // Sort by pathRps descending, take top N - paths.sort((a, b) => b.pathRps - a.pathRps); - return paths.slice(0, maxPaths); -} - -module.exports = { - fetchUpstreamNeighborhood, - findTopPathsToTarget -}; diff --git a/src/middleware/correlation.js b/src/middleware/correlation.js new file mode 100644 index 0000000..86cc716 --- /dev/null +++ b/src/middleware/correlation.js @@ -0,0 +1,65 @@ +/** + * Correlation ID Middleware + * + * Generates a unique correlation ID for each request and attaches it to: + * - req.correlationId (for downstream use) + * - X-Correlation-Id response header + * + * Also logs request start/end with structured context. + */ + +const crypto = require('node:crypto'); +const logger = require('../utils/logger'); + +/** + * Generate a UUID v4 + * @returns {string} + */ +function generateCorrelationId() { + return crypto.randomUUID(); +} + +/** + * Correlation ID middleware factory + * @returns {Function} Express middleware + */ +function correlationMiddleware() { + return (req, res, next) => { + const startTime = Date.now(); + + // Generate or use existing correlation ID (from upstream proxy) + const correlationId = req.headers['x-correlation-id'] || generateCorrelationId(); + req.correlationId = correlationId; + + // Set response header + res.setHeader('X-Correlation-Id', correlationId); + + // Log request start + logger.info('request_start', { + correlationId, + method: req.method, + path: req.path, + query: Object.keys(req.query).length > 0 ? req.query : undefined + }); + + // Capture response finish for logging + res.on('finish', () => { + const durationMs = Date.now() - startTime; + + logger.info('request_end', { + correlationId, + method: req.method, + path: req.path, + statusCode: res.statusCode, + durationMs + }); + }); + + next(); + }; +} + +module.exports = { + correlationMiddleware, + generateCorrelationId +}; diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js new file mode 100644 index 0000000..cb49e9c --- /dev/null +++ b/src/middleware/rateLimit.js @@ -0,0 +1,143 @@ +/** + * Rate Limiting Middleware + * + * In-memory sliding window rate limiter. + * Uses req.socket.remoteAddress as client identifier. + * + * Returns 429 Too Many Requests when limit exceeded. + */ + +const config = require('../config/config'); +const logger = require('../utils/logger'); + +/** + * In-memory store for request timestamps per client + * @type {Map} + */ +const requestStore = new Map(); + +/** + * Clean up old entries from the store (called periodically) + * @param {number} windowMs + */ +function cleanup(windowMs) { + const now = Date.now(); + for (const [key, timestamps] of requestStore.entries()) { + const valid = timestamps.filter(t => now - t < windowMs); + if (valid.length === 0) { + requestStore.delete(key); + } else { + requestStore.set(key, valid); + } + } +} + +// Periodic cleanup every 60 seconds +let cleanupInterval = null; + +/** + * Start cleanup interval (for production use) + */ +function startCleanup() { + if (!cleanupInterval) { + cleanupInterval = setInterval(() => cleanup(config.rateLimit.windowMs), 60000); + cleanupInterval.unref(); // Don't prevent process exit + } +} + +/** + * Stop cleanup interval (for testing) + */ +function stopCleanup() { + if (cleanupInterval) { + clearInterval(cleanupInterval); + cleanupInterval = null; + } +} + +/** + * Clear all rate limit data (for testing) + */ +function clearStore() { + requestStore.clear(); +} + +/** + * Get client identifier from request + * @param {Object} req - Express request + * @returns {string} + */ +function getClientKey(req) { + return req.socket?.remoteAddress || req.ip || 'unknown'; +} + +/** + * Rate limiting middleware factory + * @param {Object} [options] + * @param {number} [options.windowMs] - Window size in ms (default from config) + * @param {number} [options.maxRequests] - Max requests per window (default from config) + * @returns {Function} Express middleware + */ +function rateLimitMiddleware(options = {}) { + const windowMs = options.windowMs ?? config.rateLimit.windowMs; + const maxRequests = options.maxRequests ?? config.rateLimit.maxRequests; + + startCleanup(); + + return (req, res, next) => { + const clientKey = getClientKey(req); + const now = Date.now(); + + // Get existing timestamps for this client + let timestamps = requestStore.get(clientKey) || []; + + // Filter to only timestamps within the window + timestamps = timestamps.filter(t => now - t < windowMs); + + // Calculate remaining requests (subtract 1 for current request) + const remaining = Math.max(0, maxRequests - timestamps.length - 1); + const resetTime = timestamps.length > 0 + ? Math.ceil((timestamps[0] + windowMs) / 1000) + : Math.ceil((now + windowMs) / 1000); + + // Set rate limit headers + res.setHeader('X-RateLimit-Limit', maxRequests); + res.setHeader('X-RateLimit-Remaining', remaining); + res.setHeader('X-RateLimit-Reset', resetTime); + + // Check if limit exceeded + if (timestamps.length >= maxRequests) { + logger.warn('rate_limit_exceeded', { + correlationId: req.correlationId, + clientKey, + path: req.path, + limit: maxRequests, + windowMs + }); + + res.status(429).json({ + error: 'Too many requests', + retryAfterMs: timestamps[0] + windowMs - now + }); + return; + } + + // Record this request + timestamps.push(now); + requestStore.set(clientKey, timestamps); + + next(); + }; +} + +module.exports = { + rateLimitMiddleware, + getClientKey, + // Exported for testing + _test: { + clearStore, + stopCleanup, + startCleanup, + requestStore + } +}; diff --git a/src/neo4j.js b/src/neo4j.js deleted file mode 100644 index afd617d..0000000 --- a/src/neo4j.js +++ /dev/null @@ -1,116 +0,0 @@ -const neo4j = require('neo4j-driver'); -const config = require('./config'); - -/** - * @typedef {Object} EdgeData - * @property {string} source - Source service ID - * @property {string} target - Target service ID - * @property {number} rate - Request rate (RPS) - * @property {number} errorRate - Error rate (RPS) - * @property {number} p50 - P50 latency (ms) - * @property {number} p95 - P95 latency (ms) - * @property {number} p99 - P99 latency (ms) - */ - -/** - * @typedef {Object} NodeData - * @property {string} serviceId - Service ID (namespace:name) - * @property {string} name - Service name - * @property {string} namespace - Service namespace - */ - -// Initialize Neo4j driver with timeout configuration -const driver = neo4j.driver( - config.neo4j.uri, - neo4j.auth.basic(config.neo4j.user, config.neo4j.password), - { - maxConnectionLifetime: 3 * 60 * 60 * 1000, // 3 hours - maxConnectionPoolSize: 50, - connectionAcquisitionTimeout: config.simulation.timeoutMs - } -); - -/** - * Redact password from error messages for security - * @param {string} message - Error message - * @returns {string} - Redacted message - */ -function redactCredentials(message) { - if (!message) return message; - return message - .replace(new RegExp(config.neo4j.password, 'g'), '[REDACTED]') - .replace(/password=([^&\s]+)/gi, 'password=[REDACTED]'); -} - -/** - * Execute a Neo4j query with timeout enforcement - * @param {string} query - Cypher query - * @param {Object} params - Query parameters - * @param {number} [timeoutMs] - Optional timeout override - * @returns {Promise} - */ -async function executeQuery(query, params = {}, timeoutMs = config.simulation.timeoutMs) { - const session = driver.session({ - defaultAccessMode: neo4j.session.READ - }); - - try { - // Two-layer timeout enforcement - const queryPromise = session.run(query, params, { - timeout: timeoutMs - }); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Query timeout exceeded')), timeoutMs); - }); - - const result = await Promise.race([queryPromise, timeoutPromise]); - return result; - } catch (error) { - // Redact credentials from error messages - error.message = redactCredentials(error.message); - throw error; - } finally { - await session.close(); - } -} - -/** - * Check Neo4j connectivity - * @returns {Promise<{connected: boolean, services?: number, error?: string}>} - */ -async function checkHealth() { - try { - const result = await executeQuery( - 'MATCH (s:Service) RETURN count(s) AS total', - {}, - 5000 // Short timeout for health check - ); - - return { - connected: true, - services: result.records[0].get('total').toNumber() - }; - } catch (error) { - return { - connected: false, - error: redactCredentials(error.message) - }; - } -} - -/** - * Close Neo4j driver connection - * @returns {Promise} - */ -async function closeDriver() { - await driver.close(); -} - -module.exports = { - driver, - executeQuery, - checkHealth, - closeDriver, - redactCredentials -}; diff --git a/src/routes/decisions.js b/src/routes/decisions.js new file mode 100644 index 0000000..9006f4e --- /dev/null +++ b/src/routes/decisions.js @@ -0,0 +1,103 @@ +/** + * Decision logging routes + * POST /decisions/log - Log a decision + * GET /decisions/history - Get decision history + */ + +const express = require('express'); +const router = express.Router(); +const { getDecisionStore } = require('../storage/decisionStoreSingleton'); + +// Get singleton decision store +const getStore = () => getDecisionStore(); + +/** + * POST /decisions/log + * Log a decision from Pipeline Playground + */ +router.post('/log', (req, res) => { + const decisionStore = getStore(); + if (!decisionStore) { + return res.status(503).json({ + error: 'Decision store not available. Check SQLite configuration.' + }); + } + + try { + const { timestamp, type, scenario, result, correlationId } = req.body; + + // Validate required fields + if (!timestamp || !type || !scenario || !result) { + return res.status(400).json({ + error: 'Missing required fields: timestamp, type, scenario, result' + }); + } + + // Validate timestamp format (basic check) + if (isNaN(Date.parse(timestamp))) { + return res.status(400).json({ + error: 'Invalid timestamp format. Use ISO 8601 (e.g., 2026-01-04T10:00:00Z)' + }); + } + + // Validate type + const validTypes = ['failure', 'scaling', 'risk']; + if (!validTypes.includes(type)) { + return res.status(400).json({ + error: `Invalid type. Must be one of: ${validTypes.join(', ')}` + }); + } + + const store = getStore(); + const inserted = store.logDecision({ + timestamp, + type, + scenario, + result, + correlationId + }); + + res.status(201).json(inserted); + } catch (error) { + console.error('Error logging decision:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /decisions/history + * Get decision history with pagination and optional type filter + */ +router.get('/history', (req, res) => { + const decisionStore = getStore(); + if (!decisionStore) { + return res.status(503).json({ + error: 'Decision store not available. Check SQLite configuration.' + }); + } + + try { + const limit = Number.parseInt(req.query.limit) || 50; + const offset = Number.parseInt(req.query.offset) || 0; + const { type } = req.query; + + // Get decisions and total count + const store = getStore(); + const decisions = store.getHistory({ limit, offset, type }); + const total = store.getCount(type); + + res.json({ + decisions, + pagination: { + limit, + offset, + total + } + }); + } catch (error) { + console.error('Error retrieving decision history:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/src/routes/dependencyGraph.js b/src/routes/dependencyGraph.js new file mode 100644 index 0000000..731e83c --- /dev/null +++ b/src/routes/dependencyGraph.js @@ -0,0 +1,237 @@ +const express = require('express'); +const { getMetricsSnapshot, checkGraphHealth, getCentralityScores } = require('../clients/graphEngineClient'); + +const router = express.Router(); + +/** + * GET /api/dependency-graph/snapshot + * Returns enriched graph snapshot with node and edge telemetry + * + * Query params: + * - range: time range (e.g., "1h", "5m") - currently informational only + * - namespace: filter by namespace (optional) + * + * Response shape designed for Incident Explorer UI + */ +router.get('/snapshot', async (req, res) => { + try { + const { namespace } = req.query; + + // Fetch snapshot, health and centrality in parallel + const [snapshotResult, healthResult, centralityResult] = await Promise.all([ + getMetricsSnapshot(), + checkGraphHealth(), + getCentralityScores() + ]); + + // Extract freshness info + let stale = true; + let lastUpdatedSecondsAgo = null; + let windowMinutes = 5; + + if (healthResult.ok && healthResult.data) { + stale = healthResult.data.stale ?? true; + lastUpdatedSecondsAgo = healthResult.data.lastUpdatedSecondsAgo ?? null; + windowMinutes = healthResult.data.windowMinutes ?? 5; + } + + // Handle snapshot fetch failure + if (!snapshotResult.ok) { + return res.status(503).json({ + error: snapshotResult.error || 'Failed to fetch graph snapshot from Graph Engine', + nodes: [], + edges: [], + metadata: { + stale: true, + lastUpdatedSecondsAgo: null, + windowMinutes + } + }); + } + + const rawServices = snapshotResult.data?.services || []; + const rawEdges = snapshotResult.data?.edges || []; + + // Build service name -> namespace map AND metrics map + const serviceMap = new Map(); + const metricsMap = new Map(); // Key: service name, Value: metrics + + rawServices.forEach(svc => { + const ns = svc.namespace || 'default'; + serviceMap.set(svc.name, ns); + + // Extract metrics from service object + // Graph Engine returns: { name, namespace, rps, errorRate, p95, podCount, availability } + metricsMap.set(svc.name, { + requestRate: svc.rps || 0, + errorRate: svc.errorRate ? svc.errorRate * 100 : 0, // Convert to percentage + p95: svc.p95 || 0, + podCount: svc.podCount ?? 0, + availability: svc.availability !== undefined ? svc.availability * 100 : null // Convert 0-1 to percentage + }); + }); + + // Build centrality map + const centralityMap = new Map(); + if (centralityResult.ok && centralityResult.data?.scores) { + centralityResult.data.scores.forEach(s => { + centralityMap.set(s.service, s); + }); + } + + // Enrich nodes with telemetry + const nodes = rawServices + .filter(svc => !namespace || svc.namespace === namespace) + .map(svc => { + const ns = svc.namespace || 'default'; + const nodeId = `${ns}:${svc.name}`; + const metrics = metricsMap.get(svc.name) || {}; + + // Calculate risk level based on metrics + const riskLevel = calculateRiskLevel(metrics); + const riskReason = getRiskReason(metrics); + + return { + id: nodeId, + name: svc.name, + namespace: ns, + riskLevel, + riskReason, + // Aggregated telemetry (optional if unavailable) + reqRate: metrics.requestRate ?? undefined, + errorRatePct: metrics.errorRate ?? undefined, + latencyP95Ms: metrics.p95 ?? undefined, + availabilityPct: metrics.availability ?? undefined, + podCount: metrics.podCount ?? undefined, + availability: svc.availability ?? undefined, // 0-1 score from Graph Engine + pageRank: centralityMap.get(svc.name)?.pagerank, + betweenness: centralityMap.get(svc.name)?.betweenness, + updatedAt: new Date().toISOString() + }; + }); + + // Enrich edges with telemetry (if available from Graph Engine) + const edges = rawEdges + .map(e => { + const fromNs = serviceMap.get(e.from) || 'default'; + const toNs = e.namespace || 'default'; + const edgeId = `${fromNs}:${e.from}->${toNs}:${e.to}`; + + return { + id: edgeId, + source: `${fromNs}:${e.from}`, + target: `${toNs}:${e.to}`, + // Edge telemetry is at the root of the edge object in service-graph-engine + reqRate: e.rps ?? undefined, + errorRatePct: e.errorRate ? e.errorRate * 100 : undefined, // Convert 0-1 to % + latencyP95Ms: e.p95 ?? undefined + }; + }); + + // Count nodes and edges with metrics (for debugging) + const nodesWithMetrics = nodes.filter(n => + n.reqRate !== undefined || n.errorRatePct !== undefined || n.latencyP95Ms !== undefined + ).length; + const edgesWithMetrics = edges.filter(e => + e.reqRate !== undefined || e.errorRatePct !== undefined || e.latencyP95Ms !== undefined + ).length; + + res.json({ + nodes, + edges, + metadata: { + stale, + lastUpdatedSecondsAgo, + windowMinutes, + nodeCount: nodes.length, + edgeCount: edges.length, + nodesWithMetrics, + edgesWithMetrics, + generatedAt: new Date().toISOString() + } + }); + + } catch (error) { + console.error('Error fetching dependency graph snapshot:', error); + res.status(503).json({ + error: error.message || 'Graph Engine unreachable', + nodes: [], + edges: [], + metadata: { + stale: true, + lastUpdatedSecondsAgo: null, + windowMinutes: 5 + } + }); + } +}); + +/** + * Calculate availability percentage from error rate + * @param {number} errorRate - Error rate as decimal (e.g., 0.002 = 0.2%) + * @returns {number} - Availability percentage + */ +function calculateAvailability(errorRate) { + if (typeof errorRate !== 'number') return 100; + return errorRate > 0 ? (1 - errorRate) * 100 : 100; +} + +/** + * Calculate risk level based on telemetry metrics + * @param {object} metrics - Service metrics + * @returns {string} - "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "UNKNOWN" + */ +function calculateRiskLevel(metrics) { + if (!metrics || Object.keys(metrics).length === 0) { + return 'UNKNOWN'; + } + + const { errorRate, availability, p95, podCount } = metrics; + + // Critical conditions + if (podCount === 0) return 'CRITICAL'; + if (availability !== null && availability !== undefined && availability < 50) return 'CRITICAL'; + + // High risk conditions + if (errorRate > 5) return 'HIGH'; + if (availability !== null && availability !== undefined && availability < 95) return 'HIGH'; + if (p95 > 1000) return 'HIGH'; + + // Medium risk conditions + if (errorRate > 1) return 'MEDIUM'; + if (availability !== null && availability !== undefined && availability < 99) return 'MEDIUM'; + if (p95 > 500) return 'MEDIUM'; + + // No metrics available + if (availability === null || availability === undefined) return 'UNKNOWN'; + + return 'LOW'; +} + +/** + * Get human-readable risk reason + * @param {object} metrics - Service metrics + * @returns {string} - Risk reason + */ +function getRiskReason(metrics) { + if (!metrics || Object.keys(metrics).length === 0) { + return 'No recent metrics available'; + } + + const { errorRate, availability, p95, podCount } = metrics; + + if (podCount === 0) return 'No pods running'; + if (availability !== null && availability !== undefined && availability < 50) return `Critical availability (${availability.toFixed(1)}%)`; + if (errorRate > 5) return `High error rate (${errorRate.toFixed(2)}%)`; + if (availability !== null && availability !== undefined && availability < 95) return `Low availability (${availability.toFixed(1)}%)`; + if (p95 > 1000) return `P95 latency spike (${p95.toFixed(0)}ms)`; + if (errorRate > 1) return `Elevated error rate (${errorRate.toFixed(2)}%)`; + if (availability !== null && availability !== undefined && availability < 99) return `Availability degraded (${availability.toFixed(1)}%)`; + if (p95 > 500) return `Slow responses (${p95.toFixed(0)}ms)`; + + if (availability === null || availability === undefined) return 'No traffic metrics'; + + return 'Operating normally'; +} + +module.exports = router; diff --git a/src/routes/telemetry.js b/src/routes/telemetry.js new file mode 100644 index 0000000..75240ee --- /dev/null +++ b/src/routes/telemetry.js @@ -0,0 +1,119 @@ +/** + * Telemetry query routes + * GET /telemetry/service - Get service metrics from InfluxDB + * GET /telemetry/edges - Get edge metrics from InfluxDB + */ + +const express = require('express'); +const router = express.Router(); +const telemetryService = require('../services/telemetryService'); + +/** + * Validate timestamp (ISO 8601) + */ +function validateTimestamp(ts) { + const date = new Date(ts); + return !Number.isNaN(date.getTime()); +} + +/** + * Enforce max time range (7 days) + */ +function validateTimeRange(from, to) { + const fromMs = new Date(from).getTime(); + const toMs = new Date(to).getTime(); + const maxRangeMs = 7 * 24 * 60 * 60 * 1000; // 7 days + + if (toMs - fromMs > maxRangeMs) { + throw new Error('Time range exceeds maximum of 7 days'); + } +} + +/** + * GET /telemetry/service + * Get time-series metrics for a service + */ +router.get('/service', async (req, res) => { + const status = telemetryService.checkStatus(); + if (!status.enabled) { + return res.status(503).json({ error: status.error }); + } + + try { + const { service, from, to, step } = req.query; + + if (!from || !to) { + return res.status(400).json({ error: 'Missing required parameters: from, to' }); + } + + if (!validateTimestamp(from) || !validateTimestamp(to)) { + return res.status(400).json({ error: 'Invalid timestamp format' }); + } + + validateTimeRange(from, to); + + const stepSeconds = Number.parseInt(step) || 60; + const results = await telemetryService.getServiceMetrics(service, from, to, stepSeconds); + + res.json({ + service: service || 'all', + from, + to, + step: stepSeconds, + datapoints: results + }); + + } catch (error) { + console.error('Error querying InfluxDB:', error); + if (error.message.includes('Time range exceeds')) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /telemetry/edges + * Get time-series metrics for edges between services + */ +router.get('/edges', async (req, res) => { + const status = telemetryService.checkStatus(); + if (!status.enabled) { + return res.status(503).json({ error: status.error }); + } + + try { + const { fromService, toService, from, to, step } = req.query; + + if (!from || !to) { + return res.status(400).json({ error: 'Missing required parameters: from, to' }); + } + + if (!validateTimestamp(from) || !validateTimestamp(to)) { + return res.status(400).json({ error: 'Invalid timestamp format' }); + } + + validateTimeRange(from, to); + + const stepSeconds = Number.parseInt(step) || 60; + const results = await telemetryService.getEdgeMetrics(fromService, toService, from, to, stepSeconds); + + res.json({ + fromService, + toService, + from, + to, + step: stepSeconds, + datapoints: results + }); + + } catch (error) { + console.error('Error querying InfluxDB:', error); + if (error.message.includes('Time range exceeds')) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/src/scalingSimulation.js b/src/scalingSimulation.js deleted file mode 100644 index 0690457..0000000 --- a/src/scalingSimulation.js +++ /dev/null @@ -1,250 +0,0 @@ -const { fetchUpstreamNeighborhood } = require('./graph'); -const config = require('./config'); - -/** - * @typedef {import('./neo4j').EdgeData} EdgeData - * @typedef {import('./graph').GraphSnapshot} GraphSnapshot - */ - -/** - * @typedef {Object} ScalingModel - * @property {string} type - Model type (bounded_sqrt, linear) - * @property {number} [alpha] - Fixed overhead fraction (0.0-1.0) - */ - -/** - * @typedef {Object} ScalingSimulationRequest - * @property {string} serviceId - Target service ID - * @property {number} currentPods - Current pod count - * @property {number} newPods - New pod count - * @property {string} [latencyMetric] - Latency metric to use (p50, p95, p99) - * @property {ScalingModel} [model] - Scaling model configuration - * @property {number} [maxDepth] - Maximum traversal depth - */ - -/** - * @typedef {Object} AffectedCallerScaling - * @property {string} serviceId - Caller service ID - * @property {number|null} beforeMs - Weighted mean latency before scaling (null if no traffic) - * @property {number|null} afterMs - Weighted mean latency after scaling (null if no traffic) - * @property {number|null} deltaMs - Latency change (negative = improvement) - */ - -/** - * @typedef {Object} AffectedPath - * @property {string[]} path - Array of service IDs in path - * @property {number} beforeMs - Path latency before scaling - * @property {number} afterMs - Path latency after scaling - * @property {number} deltaMs - Latency change - */ - -/** - * @typedef {Object} ScalingSimulationResult - * @property {Object} target - Target service info - * @property {string} latencyMetric - Latency metric used - * @property {number} currentPods - Current pod count - * @property {number} newPods - New pod count - * @property {AffectedCallerScaling[]} affectedCallers - Callers with latency changes - * @property {AffectedPath[]} affectedPaths - Top N paths with latency changes - */ - -/** - * Apply bounded square root scaling formula - * Formula: newLatency = baseLatency * (alpha + (1 - alpha) * (1 / sqrt(ratio))) - * Clamped to minimum = baseLatency * minLatencyFactor - * - * @param {number} baseLatency - Current latency (ms) - * @param {number} currentPods - Current pod count - * @param {number} newPods - New pod count - * @param {number} alpha - Fixed overhead fraction (0.0-1.0) - * @returns {number} - New latency (ms) - */ -function applyBoundedSqrtScaling(baseLatency, currentPods, newPods, alpha) { - const ratio = newPods / currentPods; - const improvement = 1 / Math.sqrt(ratio); - const newLatency = baseLatency * (alpha + (1 - alpha) * improvement); - - // Clamp to minimum (can't improve beyond 60% of baseline by default) - const minLatency = baseLatency * config.simulation.minLatencyFactor; - return Math.max(newLatency, minLatency); -} - -/** - * Apply linear scaling formula - * Formula: newLatency = baseLatency * (currentPods / newPods) - * - * @param {number} baseLatency - Current latency (ms) - * @param {number} currentPods - Current pod count - * @param {number} newPods - New pod count - * @returns {number} - New latency (ms) - */ -function applyLinearScaling(baseLatency, currentPods, newPods) { - return baseLatency * (currentPods / newPods); -} - -/** - * Compute weighted mean latency for a service's outgoing calls - * Formula: SUM(rate * latency) / SUM(rate) - * Returns null if total rate is 0 (no traffic) - * - * @param {EdgeData[]} edges - Outgoing edges - * @param {string} metric - Latency metric (p50, p95, p99) - * @param {Map} [adjustedLatencies] - Optional adjusted latencies for specific targets - * @returns {number|null} - Weighted mean latency in ms, or null if no traffic - */ -function computeWeightedMeanLatency(edges, metric, adjustedLatencies = new Map()) { - let totalWeightedLatency = 0; - let totalRate = 0; - - for (const edge of edges) { - const rate = edge.rate; - const latency = adjustedLatencies.has(edge.target) - ? adjustedLatencies.get(edge.target) - : edge[metric]; - - totalWeightedLatency += rate * latency; - totalRate += rate; - } - - // Handle zero traffic case - if (totalRate === 0) { - return null; - } - - return totalWeightedLatency / totalRate; -} - -/** - * Simulate scaling of a service (increase/decrease pod count) - * Adjusts latencies on incoming edges to target, propagates upstream - * - * Algorithm: - * 1. Fetch k-hop upstream neighborhood - * 2. For each incoming edge to target, apply scaling formula (in-memory) - * 3. For each caller, compute weighted mean latency before/after - * 4. Compute path latencies (sum of edges along path) - * 5. Return top N paths by traffic volume - * - * @param {ScalingSimulationRequest} request - Simulation request - * @returns {Promise} - */ -async function simulateScaling(request) { - const maxDepth = request.maxDepth || config.simulation.maxTraversalDepth; - const latencyMetric = request.latencyMetric || config.simulation.defaultLatencyMetric; - const modelType = request.model?.type || config.simulation.scalingModel; - const alpha = request.model?.alpha ?? config.simulation.scalingAlpha; - - // Validate inputs - if (maxDepth < 1 || maxDepth > 3) { - throw new Error(`maxDepth must be 1, 2, or 3. Got: ${maxDepth}`); - } - if (!['p50', 'p95', 'p99'].includes(latencyMetric)) { - throw new Error(`Invalid latencyMetric: ${latencyMetric}`); - } - if (request.currentPods <= 0 || request.newPods <= 0) { - throw new Error('currentPods and newPods must be positive'); - } - if (alpha < 0 || alpha > 1) { - throw new Error('alpha must be between 0 and 1'); - } - - // Fetch upstream neighborhood (read-only Neo4j query) - const snapshot = await fetchUpstreamNeighborhood(request.serviceId, maxDepth); - - // Get target node info - const targetNode = snapshot.nodes.get(request.serviceId); - if (!targetNode) { - throw new Error(`Service not found: ${request.serviceId}`); - } - - // Apply scaling formula to all incoming edges to target (in-memory adjustment) - const adjustedLatencies = new Map(); - const incomingEdges = snapshot.incomingEdges.get(request.serviceId) || []; - - for (const edge of incomingEdges) { - const currentLatency = edge[latencyMetric]; - let newLatency; - - if (modelType === 'bounded_sqrt') { - newLatency = applyBoundedSqrtScaling( - currentLatency, - request.currentPods, - request.newPods, - alpha - ); - } else if (modelType === 'linear') { - newLatency = applyLinearScaling( - currentLatency, - request.currentPods, - request.newPods - ); - } else { - throw new Error(`Unknown scaling model: ${modelType}`); - } - - adjustedLatencies.set(request.serviceId, newLatency); - } - - // Calculate impact on direct callers - const affectedCallers = []; - - for (const edge of incomingEdges) { - const callerId = edge.source; - const callerEdges = snapshot.outgoingEdges.get(callerId) || []; - - const beforeMs = computeWeightedMeanLatency(callerEdges, latencyMetric); - const afterMs = computeWeightedMeanLatency(callerEdges, latencyMetric, adjustedLatencies); - - affectedCallers.push({ - serviceId: callerId, - beforeMs, - afterMs, - deltaMs: (beforeMs !== null && afterMs !== null) ? (afterMs - beforeMs) : null - }); - } - - // Sort by absolute delta descending (biggest improvements first) - affectedCallers.sort((a, b) => { - if (a.deltaMs === null) return 1; - if (b.deltaMs === null) return -1; - return Math.abs(b.deltaMs) - Math.abs(a.deltaMs); - }); - - // Compute path latencies (simplified: sum of edge latencies along path) - // For demo, find paths to target and compute before/after - const affectedPaths = []; - - // For each direct caller, create simple 2-hop paths - for (const edge of incomingEdges.slice(0, config.simulation.maxPathsReturned)) { - const path = [edge.source, request.serviceId]; - const beforeMs = edge[latencyMetric]; - const afterMs = adjustedLatencies.get(request.serviceId) || beforeMs; - - affectedPaths.push({ - path, - beforeMs, - afterMs, - deltaMs: afterMs - beforeMs - }); - } - - // Sort by absolute delta descending - affectedPaths.sort((a, b) => Math.abs(b.deltaMs) - Math.abs(a.deltaMs)); - - return { - target: { - serviceId: targetNode.serviceId, - name: targetNode.name, - namespace: targetNode.namespace - }, - latencyMetric, - currentPods: request.currentPods, - newPods: request.newPods, - affectedCallers: affectedCallers.slice(0, config.simulation.maxPathsReturned), - affectedPaths - }; -} - -module.exports = { - simulateScaling -}; diff --git a/src/services/telemetryService.js b/src/services/telemetryService.js new file mode 100644 index 0000000..2db0322 --- /dev/null +++ b/src/services/telemetryService.js @@ -0,0 +1,271 @@ +const { InfluxDBClient } = require('@influxdata/influxdb3-client'); +const config = require('../config/config'); + +/** + * Service to interact with InfluxDB and fetch telemetry data. + */ +class TelemetryService { + constructor() { + this.client = null; + if (config.influx.host && config.influx.token && config.influx.database) { + try { + this.client = new InfluxDBClient({ + host: config.influx.host, + token: config.influx.token, + database: config.influx.database + }); + } catch (error) { + console.error(`Failed to initialize InfluxDB client: ${error.message}`); + } + } + } + + /** + * Check if telemetry is enabled and configured. + * @returns {Object} { enabled: boolean, error?: string } + */ + checkStatus() { + if (!config.telemetry.enabled) { + return { enabled: false, error: 'Telemetry endpoints disabled. Set TELEMETRY_ENABLED=true to enable.' }; + } + if (!this.client) { + return { enabled: false, error: 'InfluxDB not configured. Set INFLUX_HOST, INFLUX_TOKEN, INFLUX_DATABASE' }; + } + return { enabled: true }; + } + + /** + * Parse time window string (e.g. '1w') into start/end timestamps. + * @param {string} windowStr + * @returns {{ from: string, to: string, stepSeconds: number }} + */ + parseTimeWindow(windowStr) { + const now = new Date(); + const to = now.toISOString(); + let fromDate = new Date(); + let stepSeconds = 3600; // default 1h step for longer ranges + + switch (windowStr) { + case '5d': + fromDate.setDate(now.getDate() - 5); + stepSeconds = 3600; + break; + case '1w': + fromDate.setDate(now.getDate() - 7); + stepSeconds = 3600; + break; + case '2w': + fromDate.setDate(now.getDate() - 14); + stepSeconds = 7200; // 2h step + break; + case '1m': + fromDate.setMonth(now.getMonth() - 1); + stepSeconds = 14400; // 4h step + break; + default: + // Default to last 1 hour if unspecified or invalid + fromDate.setHours(now.getHours() - 1); + stepSeconds = 60; + } + + return { + from: fromDate.toISOString(), + to, + stepSeconds + }; + } + + /** + * Fetch aggregated edge metrics for a set of edges over a time window. + * Useful for simulations using historical data. + * + * @param {string} fromTime ISO string + * @param {string} toTime ISO string + * @returns {Promise>} keyed by "source:target" + */ + async getAggregatedEdgeMetrics(fromTime, toTime) { + const status = this.checkStatus(); + if (!status.enabled) return new Map(); + + const query = ` + SELECT + "from" AS from_service, + "to" AS to_service, + AVG(request_rate) AS avg_request_rate, + AVG(NULLIF(error_rate, 0)) AS avg_error_rate, + AVG(NULLIF(p50, 0)) AS avg_p50, + AVG(NULLIF(p95, 0)) AS avg_p95, + AVG(NULLIF(p99, 0)) AS avg_p99 + FROM edge_metrics + WHERE time >= '${fromTime}' + AND time < '${toTime}' + GROUP BY from_service, to_service + `; + + const metricsMap = new Map(); + try { + const reader = await this.client.query(query, config.influx.database); + for await (const row of reader) { + const key = `${row.from_service}->${row.to_service}`; + metricsMap.set(key, { + requestRate: row.avg_request_rate || 0, + errorRate: row.avg_error_rate || 0, + p50: row.avg_p50 || 0, + p95: row.avg_p95 || 0, + p99: row.avg_p99 || 0 + }); + } + } catch (err) { + console.error('Error fetching aggregated edge metrics:', err); + } + + return metricsMap; + } + + /** + * Fetch aggregated node metrics (CPU/RAM) over a time window. + * @param {string} fromTime ISO string + * @param {string} toTime ISO string + * @returns {Promise>} keyed by node name + */ + async getAggregatedNodeMetrics(fromTime, toTime) { + const status = this.checkStatus(); + if (!status.enabled) return new Map(); + + const query = ` + SELECT + node, + AVG(cpu_usage_percent) AS avg_cpu, + AVG(ram_used_mb) AS avg_ram + FROM node_metrics + WHERE time >= '${fromTime}' + AND time < '${toTime}' + GROUP BY node + `; + + const metricsMap = new Map(); + try { + const reader = await this.client.query(query, config.influx.database); + for await (const row of reader) { + metricsMap.set(row.node, { + cpuUsagePercent: row.avg_cpu || 0, + ramUsageMB: row.avg_ram || 0 + }); + } + } catch (err) { + console.error('Error fetching aggregated node metrics:', err); + } + + return metricsMap; + } + + /** + * Fetch service metrics. + * @param {string} service + * @param {string} from + * @param {string} to + * @param {number} stepSeconds + */ + async getServiceMetrics(service, from, to, stepSeconds) { + const serviceFilter = service ? `service = '${service.replaceAll("'", "''")}'` : '1=1'; + const query = ` + SELECT + DATE_BIN(INTERVAL '${stepSeconds} seconds', time, '1970-01-01T00:00:00Z'::TIMESTAMP) AS bucket, + service, + namespace, + AVG(request_rate) AS avg_request_rate, + AVG(error_rate) AS avg_error_rate, + AVG(p50) AS avg_p50, + AVG(p95) AS avg_p95, + AVG(p99) AS avg_p99, + AVG(availability) AS avg_availability + FROM service_metrics + WHERE ${serviceFilter} + AND time >= '${from}' + AND time < '${to}' + GROUP BY bucket, service, namespace + ORDER BY bucket ASC + `; + + const results = []; + const reader = await this.client.query(query, config.influx.database); + + for await (const row of reader) { + results.push({ + timestamp: row.bucket, + service: row.service, + namespace: row.namespace, + requestRate: row.avg_request_rate, + errorRate: row.avg_error_rate, + p50: row.avg_p50, + p95: row.avg_p95, + p99: row.avg_p99, + availability: row.avg_availability + }); + } + return results; + } + + /** + * Fetch edge metrics. + * @param {string} fromService + * @param {string} toService + * @param {string} from + * @param {string} to + * @param {number} stepSeconds + */ + async getEdgeMetrics(fromService, toService, from, to, stepSeconds) { + const conditions = [ + `time >= '${from}'`, + `time < '${to}'` + ]; + + if (fromService) { + conditions.push(`"from" = '${fromService.replaceAll("'", "''")}'`); + } + + if (toService) { + conditions.push(`"to" = '${toService.replaceAll("'", "''")}'`); + } + + const query = ` + SELECT + DATE_BIN(INTERVAL '${stepSeconds} seconds', time, '1970-01-01T00:00:00Z'::TIMESTAMP) AS bucket, + "from" AS from_service, + "to" AS to_service, + namespace, + AVG(request_rate) AS avg_request_rate, + AVG(error_rate) AS avg_error_rate, + AVG(p50) AS avg_p50, + AVG(p95) AS avg_p95, + AVG(p99) AS avg_p99 + FROM edge_metrics + WHERE ${conditions.join(' AND ')} + GROUP BY bucket, from_service, to_service, namespace + ORDER BY bucket ASC + `; + + const results = []; + const reader = await this.client.query(query, config.influx.database); + + for await (const row of reader) { + results.push({ + timestamp: row.bucket, + from: row.from_service, + to: row.to_service, + namespace: row.namespace, + requestRate: row.avg_request_rate, + errorRate: row.avg_error_rate, + p50: row.avg_p50, + p95: row.avg_p95, + p99: row.avg_p99 + }); + } + return results; + } +} + +// Singleton instance +const telemetryService = new TelemetryService(); + +module.exports = telemetryService; diff --git a/src/simulation/addSimulation.js b/src/simulation/addSimulation.js new file mode 100644 index 0000000..9710c5d --- /dev/null +++ b/src/simulation/addSimulation.js @@ -0,0 +1,304 @@ +const { getServicesWithPlacement } = require('../clients/graphEngineClient'); +const config = require('../config/config'); +const telemetryService = require('../services/telemetryService'); + +/** + * @typedef {Object} AddSimulationRequest + * @property {string} serviceName - Name of the new service + * @property {number} cpuRequest - CPU cores requested per pod + * @property {number} ramRequest - RAM MB requested per pod + * @property {number} replicas - Number of replicas + * @property {string} [timeWindow] - Historical time window (e.g. '1w') + */ + +/** + * @typedef {Object} NodeCapacity + * @property {string} node - Node name + * @property {number} cpuAvailable - Available CPU cores + * @property {number} ramAvailableMB - Available RAM in MB + * @property {number} cpuTotal - Total CPU cores + * @property {number} ramTotalMB - Total RAM in MB + * @property {boolean} canFit - Whether this node can fit at least one pod + * @property {number} maxPods - Max pods this node can fit + */ + +/** + * @typedef {Object} AddSimulationResult + * @property {boolean} success - Whether the placement is possible for all replicas + * @property {string} confidence - 'high' or 'low' based on data freshness + * @property {string} explanation - Human readable explanation + * @property {Array} nodeAnalysis - Analysis of each node's capacity + * @property {Object} recommendation - Placement recommendation + * @property {Array<{node: string, replicas: number}>} recommendation.distribution - Recommended pod distribution + * @property {number} totalCapacityPods - Total number of pods the cluster can fit + */ + +/** + * Simulate adding a new service to the cluster. + * Checks if there is enough capacity (CPU/RAM) on existing nodes to schedule the requested pods. + * + * @param {AddSimulationRequest} request + * @returns {Promise} + */ +async function simulateAdd(request) { + const { serviceName, cpuRequest = 0.1, ramRequest = 128, replicas = 1, dependencies = [], timeWindow } = request; + + // Validate inputs + if (cpuRequest <= 0 || ramRequest <= 0 || replicas <= 0) { + throw new Error('Invalid resource requests: cpu, ram, and replicas must be positive'); + } + + // 1. Fetch current cluster state + const result = await getServicesWithPlacement(); + + if (!result.ok) { + throw new Error(`Failed to fetch cluster state: ${result.error}`); + } + + // 2. Extract Node Metrics + // We need to look at all unique nodes and their current usage + const nodeMap = new Map(); + + // Iterate through all services to find all nodes and their reported usage + const services = result.data.services || []; + services.forEach(svc => { + if (svc.placement && svc.placement.nodes) { + svc.placement.nodes.forEach(n => { + if (!n.node) return; + + // We assume the node resource totals are consistent across reports + // Usage needs to be aggregated or taken from the node-level report + // In graphEngineClient type defs, it says placement.nodes has: + // resources: { cpu: { usagePercent, cores }, ram: { usedMB, totalMB } } + + // If we haven't seen this node, add it + if (!nodeMap.has(n.node)) { + nodeMap.set(n.node, { + name: n.node, + cpuUsagePercent: n.resources?.cpu?.usagePercent || 0, + cpuCores: n.resources?.cpu?.cores || 0, + ramUsedMB: n.resources?.ram?.usedMB || 0, + ramTotalMB: n.resources?.ram?.totalMB || 0 + }); + } + }); + } + }); + + // OVERRIDE with historical metrics if requested + if (timeWindow) { + try { + const { from, to } = telemetryService.parseTimeWindow(timeWindow); + const aggregatedNodes = await telemetryService.getAggregatedNodeMetrics(from, to); + + // Loop through our known nodes and update their usage with historical averages + for (const [nodeName, nodeData] of nodeMap) { + const history = aggregatedNodes.get(nodeName); + if (history) { + nodeData.cpuUsagePercent = history.cpuUsagePercent; + nodeData.ramUsedMB = history.ramUsageMB; + nodeData.isHistorical = true; + } + } + } catch (err) { + console.error('Failed to overlay historical node metrics:', err); + } + } + + const nodes = Array.from(nodeMap.values()); + + if (nodes.length === 0) { + throw new Error('No nodes found in cluster state. Cannot perform placement analysis.'); + } + + // --- HOST RESOURCE DEDUPLICATION (Minikube Fix) --- + // If multiple nodes are detected as "minikube", they likely share the host's resources. + // Standard reporting (e.g. docker driver) often reports the full Host CPU/RAM for all nodes, leading to double counting. + // We adjust available capacity by treating them as a shared pool. + + const minikubeNodes = nodes.filter(n => n.name.toLowerCase().includes('minikube')); + + if (minikubeNodes.length > 1) { + // Assume shared host: Capacity is the MAX of any node (assuming identical reporting), Usage is the SUM of all nodes. + const sharedCpuTotal = Math.max(...minikubeNodes.map(n => n.cpuCores)); + const sharedRamTotal = Math.max(...minikubeNodes.map(n => n.ramTotalMB)); + + const sharedCpuUsed = minikubeNodes.reduce((sum, n) => sum + ((n.cpuUsagePercent / 100) * n.cpuCores), 0); + const sharedRamUsed = minikubeNodes.reduce((sum, n) => sum + n.ramUsedMB, 0); + + const sharedCpuAvailable = Math.max(0, sharedCpuTotal - sharedCpuUsed); + const sharedRamAvailable = Math.max(0, sharedRamTotal - sharedRamUsed); + + // Apply the tighter constraint (Shared Available vs Node Reported Available) + // We override the values in the node objects so the downstream analysis uses the corrected values. + minikubeNodes.forEach(node => { + // Node reported available + const nodeCpuAvail = Math.max(0, node.cpuCores - ((node.cpuUsagePercent / 100) * node.cpuCores)); + const nodeRamAvail = Math.max(0, node.ramTotalMB - node.ramUsedMB); + + // Effective available is the minimum of local node headroom and global shared headroom + node.effectiveCpuAvailable = Math.min(nodeCpuAvail, sharedCpuAvailable); + node.effectiveRamAvailable = Math.min(nodeRamAvail, sharedRamAvailable); + }); + } + + // 3. Analyze Capacity per Node + const nodeAnalysis = nodes.map(node => { + // Use effective available if calculated (minikube), else calculate standard + let cpuAvailable, ramAvailable; + + if (node.effectiveCpuAvailable !== undefined) { + cpuAvailable = node.effectiveCpuAvailable; + ramAvailable = node.effectiveRamAvailable; + } else { + const cpuUsed = (node.cpuUsagePercent / 100) * node.cpuCores; + cpuAvailable = Math.max(0, node.cpuCores - cpuUsed); + ramAvailable = Math.max(0, node.ramTotalMB - node.ramUsedMB); + } + + // Check how many pods fit + // Constraint: Pod fits if CPU <= Available AND RAM <= Available + const cpuFit = Math.floor(cpuAvailable / cpuRequest); + const ramFit = Math.floor(ramAvailable / ramRequest); + const maxPods = Math.min(cpuFit, ramFit); + + return { + node: node.name, + cpuAvailable: Number.parseFloat(cpuAvailable.toFixed(2)), + ramAvailableMB: Number.parseFloat(ramAvailable.toFixed(2)), + cpuTotal: node.cpuCores, + ramTotalMB: node.ramTotalMB, + canFit: maxPods > 0, + maxPods + }; + }); + + // 4. Generate Recommendation (Greedy Strategy with Scoring) + // Sort nodes by remaining capacity score + // Score based on how 'easily' it fits relative to available resources + const scoredNodes = nodeAnalysis.map(n => { + let score = 0; + if (n.canFit) { + // Calculate PROJECTED headroom (after placing the pod) + // This makes the score sensitive to the size of the request. + // A large pod that uses up most of the remaining space should result in a lower score (tighter fit). + const projectedCpu = Math.max(0, n.cpuAvailable - cpuRequest); + const projectedRam = Math.max(0, n.ramAvailableMB - ramRequest); + + const cpuHeadroom = n.cpuTotal > 0 ? projectedCpu / n.cpuTotal : 0; + const ramHeadroom = n.ramTotalMB > 0 ? projectedRam / n.ramTotalMB : 0; + + // Base 50 + up to 50 for projected headroom + score = Math.floor(50 + ((cpuHeadroom + ramHeadroom) / 2) * 50); + } else { + // 0-49 based on how close it is + const cpuFrac = n.cpuTotal > 0 ? Math.min(1, n.cpuAvailable / cpuRequest) : 0; + const ramFrac = n.ramTotalMB > 0 ? Math.min(1, n.ramAvailableMB / ramRequest) : 0; + score = Math.floor(((cpuFrac + ramFrac) / 2) * 40); + } + + return { + ...n, + score, + // Add UI-friendly fields + nodeName: n.node, + suitable: n.canFit, + reason: n.canFit ? undefined : (n.cpuAvailable < cpuRequest ? 'Insufficient CPU' : 'Insufficient RAM'), + availableCpu: n.cpuAvailable, + availableRam: n.ramAvailableMB + }; + }); + + // Sort by score descending + scoredNodes.sort((a, b) => b.score - a.score); + + const totalCapacityPods = nodeAnalysis.reduce((sum, n) => sum + n.maxPods, 0); + + // Distribution + let remainingReplicas = replicas; + const distribution = []; + + for (const node of scoredNodes) { + if (remainingReplicas <= 0) break; + if (node.maxPods > 0) { + const take = Math.min(remainingReplicas, node.maxPods); + distribution.push({ node: node.node, replicas: take }); + remainingReplicas -= take; + } + } + + const success = remainingReplicas === 0; + + // --- Risk Analysis --- + let dependencyRisk = 'low'; + let riskDescription = 'No major risks detected.'; + const missingDeps = []; + + if (dependencies && dependencies.length > 0) { + dependencies.forEach(dep => { + const depServiceId = dep.serviceId; + const exists = services.some(s => { + const sId = s.serviceId || `${s.namespace}:${s.name}`; + return sId === depServiceId; + }); + if (!exists) { + missingDeps.push(depServiceId); + } + }); + + if (missingDeps.length > 0) { + dependencyRisk = 'high'; + riskDescription = `Missing dependencies in cluster: ${missingDeps.join(', ')}.`; + } else if (dependencies.length > 3) { + dependencyRisk = 'medium'; + riskDescription = 'High number of dependencies increases complexity.'; + } else { + riskDescription = 'All dependencies verified in current graph.'; + } + } else { + riskDescription = 'No dependencies declared.'; + } + + // 5. Build Result + const recommendations = []; + if (success) { + recommendations.push({ + type: 'placement', + priority: 'high', + description: `Place ${replicas} replicas across ${distribution.length} nodes: ${distribution.map(d => `${d.replicas} on ${d.node}`).join(', ')}.` + }); + } else { + recommendations.push({ + type: 'scaling', + priority: 'critical', + description: `Insufficient capacity. Can only place ${replicas - remainingReplicas} replicas. Add nodes or reduce request.` + }); + } + + return { + targetServiceName: serviceName, + success, + confidence: 'high', + explanation: success + ? `Successfully found placement for all replicas.` + : `Failed to find placement for all replicas. Capacity limited to ${totalCapacityPods} pods.`, + totalCapacityPods, + suitableNodes: scoredNodes, // Matches frontend expectation + riskAnalysis: { + dependencyRisk, + description: riskDescription + }, + recommendations, + // Keep old fields just in case? Or cleaner to remove? + // Let's keep nodeAnalysis as just the array of capacities if needed, but 'suitableNodes' has it all. + // openapi spec needs update to match this structure. + recommendation: { // For backward compat with my previous change/spec + serviceName, + cpuRequest, + ramRequest, + distribution + } + }; +} + +module.exports = { simulateAdd }; diff --git a/src/simulation/failureSimulation.js b/src/simulation/failureSimulation.js new file mode 100644 index 0000000..41c9219 --- /dev/null +++ b/src/simulation/failureSimulation.js @@ -0,0 +1,447 @@ +const { getProvider } = require('../storage/providers'); +const { findTopPathsToTarget } = require('./pathAnalysis'); +const { generateFailureRecommendations } = require('../utils/recommendations'); +const { createTrace } = require('../utils/trace'); +const config = require('../config/config'); + +/** + * @typedef {import('./providers/GraphDataProvider').EdgeData} EdgeData + * @typedef {import('./providers/GraphDataProvider').GraphSnapshot} GraphSnapshot + */ + +// ============================================================================ +// Service ID Helpers (ensure canonical namespace:name format) +// ============================================================================ + +/** + * Parse a service reference into namespace and name + * Handles both "namespace:name" and plain "name" formats + * + * @param {string} idOrName - Service ID or name + * @returns {{namespace: string, name: string}} + */ +function parseServiceRef(idOrName) { + if (!idOrName) return { namespace: 'default', name: '' }; + + const str = String(idOrName); + const colonIdx = str.indexOf(':'); + + if (colonIdx > 0) { + return { + namespace: str.slice(0, colonIdx) || 'default', + name: str.slice(colonIdx + 1) || '' + }; + } + return { namespace: 'default', name: str }; +} + +/** + * Create canonical serviceId in "namespace:name" format + * + * @param {string} namespace + * @param {string} name + * @returns {string} + */ +function toCanonicalServiceId(namespace, name) { + const ns = namespace || 'default'; + return `${ns}:${name}`; +} + +/** + * Convert a node to output reference with canonical serviceId + * + * @param {Object|undefined} node - Node from snapshot + * @param {string} fallbackKey - Key to parse if node is missing + * @returns {{serviceId: string, name: string, namespace: string}} + */ +function nodeToOutRef(node, fallbackKey) { + const parsed = parseServiceRef(fallbackKey); + const name = node?.name ?? parsed.name; + const namespace = node?.namespace ?? parsed.namespace; + return { + serviceId: toCanonicalServiceId(namespace, name), + name, + namespace + }; +} + +// ============================================================================ +// Reachability Analysis Helpers +// ============================================================================ + +/** + * Find entrypoint nodes (nodes with no incoming edges within the snapshot) + * These are the "roots" from which we can traverse to find reachable nodes + * + * @param {GraphSnapshot} snapshot + * @param {string} blockedKey - Node to exclude (the failed target) + * @returns {string[]} + */ +function pickEntrypoints(snapshot, blockedKey) { + const keys = Array.from(snapshot.nodes.keys()).filter(k => k !== blockedKey); + + // First choice: nodes with no incoming edges (within the neighborhood) + let entrypoints = keys.filter(k => (snapshot.incomingEdges.get(k)?.length || 0) === 0); + + // Fallback: if neighborhood is truncated and has no "true roots", use all nodes except target + if (entrypoints.length === 0) entrypoints = keys; + + return entrypoints; +} + +/** + * BFS to find all nodes reachable from entrypoints, excluding blocked node + * + * @param {GraphSnapshot} snapshot + * @param {string[]} entrypoints - Starting nodes + * @param {string} blockedKey - Node to treat as removed + * @returns {Set} - Set of reachable node keys + */ +function computeReachableNodes(snapshot, entrypoints, blockedKey) { + const visited = new Set(); + const queue = []; + + for (const e of entrypoints) { + if (!e || e === blockedKey) continue; + visited.add(e); + queue.push(e); + } + + while (queue.length > 0) { + const cur = queue.shift(); + const outs = snapshot.outgoingEdges.get(cur) || []; + + for (const edge of outs) { + const nxt = edge.target; + if (!nxt || nxt === blockedKey) continue; + if (!snapshot.nodes.has(nxt)) continue; + if (visited.has(nxt)) continue; + + visited.add(nxt); + queue.push(nxt); + } + } + + return visited; +} + +/** + * Estimate lost traffic for unreachable nodes. + * Splits loss into: + * - lostFromTargetRps: traffic that used to come from the failed/blocked node + * - lostFromReachableCutsRps: traffic from other reachable sources now cut off + * - lostTotalRps: sum of both + * + * @param {GraphSnapshot} snapshot + * @param {Set} reachableSet + * @param {string} blockedKey + * @returns {Map} + */ +function estimateBoundaryLostTraffic(snapshot, reachableSet, blockedKey) { + const unreachableKeys = Array.from(snapshot.nodes.keys()) + .filter(k => k !== blockedKey && !reachableSet.has(k)); + + const lostByNode = new Map(); + + for (const nodeKey of unreachableKeys) { + const incoming = snapshot.incomingEdges.get(nodeKey) || []; + + let lostFromTargetRps = 0; + let lostFromReachableCutsRps = 0; + + for (const e of incoming) { + const rate = e.rate ?? 0; + + if (e.source === blockedKey) { + lostFromTargetRps += rate; + continue; + } + + if (reachableSet.has(e.source)) { + lostFromReachableCutsRps += rate; + } + } + + const lostTotalRps = lostFromTargetRps + lostFromReachableCutsRps; + + lostByNode.set(nodeKey, { lostFromTargetRps, lostFromReachableCutsRps, lostTotalRps }); + } + + return lostByNode; +} + +/** + * @typedef {Object} FailureSimulationRequest + * @property {string} serviceId - Target service ID + * @property {number} [maxDepth] - Maximum traversal depth (default from config) + */ + +/** + * @typedef {Object} AffectedCaller + * @property {string} serviceId - Caller service ID + * @property {number} lostTrafficRps - Lost traffic in requests per second + * @property {number} edgeErrorRate - Error rate on removed edge + */ + +/** + * @typedef {Object} BrokenPath + * @property {string[]} path - Array of service IDs in path + * @property {number} pathRps - Path throughput (bottleneck rate) + */ + +/** + * @typedef {Object} FailureSimulationResult + * @property {Object} target - Target service info + * @property {number} depth - Traversal depth used + * @property {AffectedCaller[]} affectedCallers - Direct callers impacted + * @property {BrokenPath[]} criticalPathsToTarget - Top N caller→target paths that become unavailable + */ + +/** + * Simulate failure of a service (treated as unavailable for path analysis) + * Calculates traffic loss and identifies caller→target paths that become unavailable + * + * Algorithm: + * 1. Fetch k-hop upstream neighborhood + * 2. If timeWindow provided, fetch aggregated metrics and overlay on snapshot + * 3. Treat target as unavailable (not actually removed from snapshot) + * 4. For each direct caller, aggregate lostTrafficRps (sum of all edge rates to target) + * 5. Find top N caller→target paths (sorted by pathRps) + * + * @param {FailureSimulationRequest} request - Simulation request + * @param {Object} options - Optional parameters (traceOptions, correlationId) + * @returns {Promise} + */ +async function simulateFailure(request, options = {}) { + const maxDepth = request.maxDepth || config.simulation.maxTraversalDepth; + const timeWindow = request.timeWindow; + const trace = options.trace || createTrace(options.traceOptions || {}); + + // Validate depth (must be integer 1-3) + if (!Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > 3) { + throw new Error(`maxDepth must be integer 1, 2, or 3. Got: ${maxDepth}`); + } + + // Fetch upstream neighborhood via Graph Engine + const provider = getProvider(); + const snapshot = await provider.fetchUpstreamNeighborhood(request.serviceId, maxDepth, { trace }); + + // ======================================================================== + // Optional: Overlay Time Window Telemetry + // ======================================================================== + if (timeWindow) { + const telemetryService = require('../services/telemetryService'); + const { from, to } = telemetryService.parseTimeWindow(timeWindow); + + await trace.stage('overlay-telemetry', async () => { + const metricsMap = await telemetryService.getAggregatedEdgeMetrics(from, to); + + // Overlay metrics on existing edges in the snapshot + for (const edge of snapshot.edges) { + const key = `${edge.source}->${edge.target}`; + const metrics = metricsMap.get(key); + + if (metrics) { + edge.rate = metrics.requestRate; + edge.errorRate = metrics.errorRate; + } + } + }); + + trace.setSummary('overlay-telemetry', { timeWindow, from, to }); + } + + // Use normalized target key from snapshot (handles namespace:name vs plain name difference) + const targetKey = snapshot.targetKey || request.serviceId; + + // Get target node info + const targetNode = snapshot.nodes.get(targetKey); + if (!targetNode) { + throw new Error(`Service not found: ${request.serviceId}`); + } + + // Build canonical target reference + const targetOut = nodeToOutRef(targetNode, targetKey); + + // Find all direct callers of target + const directCallers = snapshot.incomingEdges.get(targetKey) || []; + + // Aggregate lost traffic by caller (handles duplicate edges to same target) + const callerMap = new Map(); + for (const edge of directCallers) { + const id = edge.source; + const callerNode = snapshot.nodes.get(id); + const callerOut = nodeToOutRef(callerNode, id); + + const prev = callerMap.get(id) || { + serviceId: callerOut.serviceId, + name: callerOut.name, + namespace: callerOut.namespace, + lostTrafficRps: 0, + edgeErrorRate: 0 + }; + + prev.lostTrafficRps += edge.rate; + // Use max error rate as worst-case for this caller + prev.edgeErrorRate = Math.max(prev.edgeErrorRate, edge.errorRate); + + callerMap.set(id, prev); + } + + // Convert to array and sort by lost traffic descending + const affectedCallers = Array.from(callerMap.values()) + .sort((a, b) => b.lostTrafficRps - a.lostTrafficRps); + + // Find top N paths to target (de-duplicated by path key) + const rawPaths = await trace.stage('path-analysis', async () => { + return findTopPathsToTarget( + snapshot, + targetKey, + maxDepth, + config.simulation.maxPathsReturned * 2 // Fetch extra to allow for de-dupe + ); + }); + + // De-duplicate paths by join key + const seenPaths = new Set(); + const criticalPathsToTarget = []; + for (const pathInfo of rawPaths) { + const key = pathInfo.path.join('->'); + if (seenPaths.has(key)) continue; + seenPaths.add(key); + criticalPathsToTarget.push(pathInfo); + if (criticalPathsToTarget.length >= config.simulation.maxPathsReturned) break; + } + + // Add path-analysis summary to trace + trace.setSummary('path-analysis', { + pathsFound: rawPaths.length, + pathsReturned: criticalPathsToTarget.length + }); + + // ======================================================================== + // Phase 3: Downstream and Unreachable Impact Analysis + // ======================================================================== + + // Direct downstream dependents of target (services the target calls) + const directCallees = snapshot.outgoingEdges.get(targetKey) || []; + const downstreamMap = new Map(); + + for (const edge of directCallees) { + const calleeKey = edge.target; + if (!calleeKey || calleeKey === targetKey) continue; + + const calleeNode = snapshot.nodes.get(calleeKey); + const calleeOut = nodeToOutRef(calleeNode, calleeKey); + + const prev = downstreamMap.get(calleeKey) || { + serviceId: calleeOut.serviceId, + name: calleeOut.name, + namespace: calleeOut.namespace, + lostTrafficRps: 0, + edgeErrorRate: 0 + }; + + prev.lostTrafficRps += edge.rate ?? 0; + prev.edgeErrorRate = Math.max(prev.edgeErrorRate, edge.errorRate ?? 0); + + downstreamMap.set(calleeKey, prev); + } + + const affectedDownstream = Array.from(downstreamMap.values()) + .sort((a, b) => b.lostTrafficRps - a.lostTrafficRps); + + // Compute reachability after "removing" the target (inside trace stage) + const { unreachableServices, totalLostTrafficRps } = await trace.stage('compute-impact', async () => { + const entrypoints = pickEntrypoints(snapshot, targetKey); + const reachable = computeReachableNodes(snapshot, entrypoints, targetKey); + const lostByNode = estimateBoundaryLostTraffic(snapshot, reachable, targetKey); + + const unreachableList = Array.from(snapshot.nodes.keys()) + .filter(k => k !== targetKey && !reachable.has(k)) + .map(k => { + const n = snapshot.nodes.get(k); + const out = nodeToOutRef(n, k); + const loss = lostByNode.get(k) || { lostFromTargetRps: 0, lostFromReachableCutsRps: 0, lostTotalRps: 0 }; + return { + ...out, + lostTrafficRps: loss.lostTotalRps, + lostFromTargetRps: loss.lostFromTargetRps, + lostFromReachableCutsRps: loss.lostFromReachableCutsRps + }; + }) + .sort((a, b) => b.lostTrafficRps - a.lostTrafficRps); + + const totalLost = affectedCallers.reduce((sum, c) => sum + c.lostTrafficRps, 0); + + return { unreachableServices: unreachableList, totalLostTrafficRps: totalLost }; + }); + + // Add compute-impact summary to trace + trace.setSummary('compute-impact', { + affectedCallersCount: affectedCallers.length, + affectedDownstreamCount: affectedDownstream.length, + unreachableCount: unreachableServices.length, + totalLostTrafficRps + }); + + // Determine data confidence based on staleness + const dataFreshness = snapshot.dataFreshness ?? null; + const confidence = dataFreshness?.stale ? 'low' : 'high'; + + // Build explanation for operators + const explanation = `If ${targetOut.name} fails, ${affectedCallers.length} upstream caller(s) lose direct access, ` + + `${affectedDownstream.length} downstream service(s) lose traffic from this target, ` + + `and ${unreachableServices.length} service(s) may become unreachable within the ${maxDepth}-hop neighborhood.`; + + // Build result object (without recommendations first) + const result = { + target: targetOut, + neighborhood: { + description: 'k-hop neighborhood subgraph around target (not full graph)', + serviceCount: snapshot.nodes.size, + edgeCount: snapshot.edges.length, + depthUsed: maxDepth, + generatedAt: new Date().toISOString() + }, + dataFreshness, + confidence, + explanation, + affectedCallers, + affectedDownstream, + unreachableServices, + criticalPathsToTarget, + totalLostTrafficRps + }; + + // Generate recommendations based on result (inside trace stage) + result.recommendations = await trace.stage('recommendations', async () => { + return generateFailureRecommendations(result); + }); + + // Add recommendations summary to trace + trace.setSummary('recommendations', { + recommendationCount: result.recommendations.length + }); + + // Attach pipeline trace if enabled + const pipelineTrace = trace.finalize(); + if (pipelineTrace) { + result.pipelineTrace = pipelineTrace; + } + + return result; +} + +module.exports = { + simulateFailure, + // Exported for unit testing + _test: { + parseServiceRef, + toCanonicalServiceId, + nodeToOutRef, + pickEntrypoints, + computeReachableNodes, + estimateBoundaryLostTraffic + } +}; diff --git a/src/simulation/pathAnalysis.js b/src/simulation/pathAnalysis.js new file mode 100644 index 0000000..48662ea --- /dev/null +++ b/src/simulation/pathAnalysis.js @@ -0,0 +1,85 @@ +/** + * Path Analysis Functions + * + * Pure computational functions for analyzing paths in a graph snapshot. + * These functions work on in-memory data structures provided by GraphDataProvider. + */ + +const config = require('../config/config'); + +/** + * @typedef {import('./providers/GraphDataProvider').GraphSnapshot} GraphSnapshot + */ + +/** + * Find top N paths by traffic volume (bottleneck throughput) + * Uses min(edge.rate) along path as proxy for path throughput + * Hard-capped to prevent combinatorial explosion + * + * @param {GraphSnapshot} snapshot - Graph snapshot + * @param {string} targetServiceId - Target service ID + * @param {number} maxDepth - Maximum hops (edges) in path + * @param {number} [maxPaths] - Maximum paths to return (default from config) + * @returns {Array<{path: string[], pathRps: number}>} + */ +function findTopPathsToTarget(snapshot, targetServiceId, maxDepth, maxPaths = config.simulation.maxPathsReturned) { + const paths = []; + const visited = new Set(); + + // DFS to enumerate paths (limited by maxPaths hard cap) + // Uses hop-based depth: hops = currentPath.length - 1 (edges, not nodes) + function dfs(currentId, currentPath, minRate) { + if (paths.length >= maxPaths * 2) return; // Safety: early exit at 2x limit + + const hops = currentPath.length - 1; // hops = number of edges traversed + + // Found target with at least 1 hop + if (currentId === targetServiceId && hops >= 1) { + paths.push({ + path: [...currentPath], + pathRps: minRate + }); + return; + } + + // Stop exploring if we've reached max hops + if (hops >= maxDepth) return; + + // Sort outgoing edges for determinism: by rate desc, then target name asc + const outgoing = (snapshot.outgoingEdges.get(currentId) || []) + .slice() + .sort((e1, e2) => (e2.rate - e1.rate) || e1.target.localeCompare(e2.target)); + + for (const edge of outgoing) { + if (visited.has(edge.target)) continue; // Prevent cycles + + visited.add(edge.target); + currentPath.push(edge.target); + + const newMinRate = Math.min(minRate, edge.rate); + dfs(edge.target, currentPath, newMinRate); + + currentPath.pop(); + visited.delete(edge.target); + } + } + + // Start DFS from all nodes (except target), sorted for determinism + const startNodeIds = Array.from(snapshot.nodes.keys()).sort((a, b) => a.localeCompare(b)); + for (const nodeId of startNodeIds) { + if (nodeId === targetServiceId) continue; + if (paths.length >= maxPaths * 2) break; + + visited.clear(); + visited.add(nodeId); + dfs(nodeId, [nodeId], Infinity); + } + + // Sort by pathRps descending (already deterministic via sorted exploration) + paths.sort((a, b) => b.pathRps - a.pathRps); + return paths.slice(0, maxPaths); +} + +module.exports = { + findTopPathsToTarget +}; diff --git a/src/simulation/riskAnalysis.js b/src/simulation/riskAnalysis.js new file mode 100644 index 0000000..0e803c5 --- /dev/null +++ b/src/simulation/riskAnalysis.js @@ -0,0 +1,175 @@ +/** + * Risk Analysis Module + * + * Provides centrality-based risk scoring for services. + * Higher centrality = higher risk if the service fails. + */ + +const { getCentralityTop, checkGraphHealth } = require('../clients/graphEngineClient'); + +/** + * Risk level thresholds based on centrality score percentile + */ +const RISK_THRESHOLDS = { + high: 0.2, // Top 20% centrality + medium: 0.1, // 10-20% centrality + low: 0 // Below 10% +}; + +/** + * Determine risk level based on centrality score and rank + * @param {number} score - Centrality score + * @param {number} rank - Rank in the list (0-indexed) + * @param {number} total - Total number of services returned + * @returns {string} - Risk level (high, medium, low) + */ +function determineRiskLevel(score, rank, total) { + // Handle edge case: empty list or zero total + if (total === 0) { + return 'low'; + } + + // Top 20% of returned services = high risk + const percentile = rank / total; + + if (score > 0 && percentile < 0.2) { + return 'high'; + } else if (score > 0 && percentile < 0.5) { + return 'medium'; + } + return 'low'; +} + +/** + * Generate explanation for risk level + * @param {string} serviceName - Service name + * @param {string} metric - Centrality metric used + * @param {number} score - Centrality score + * @param {string} riskLevel - Determined risk level + * @returns {string} + */ +function generateExplanation(serviceName, metric, score, riskLevel) { + const metricLabel = metric === 'pagerank' ? 'PageRank' : 'betweenness centrality'; + + if (riskLevel === 'high') { + return `${serviceName} has high ${metricLabel} (${score.toFixed(4)}), indicating it is a critical hub. Failure could cascade widely.`; + } else if (riskLevel === 'medium') { + return `${serviceName} has moderate ${metricLabel} (${score.toFixed(4)}). Monitor for dependencies.`; + } + return `${serviceName} has low ${metricLabel} (${score.toFixed(4)}). Lower risk of cascade.`; +} + +/** + * @typedef {Object} RiskService + * @property {string} serviceId - Canonical service ID (namespace:name) + * @property {string} name - Service name + * @property {string} namespace - Service namespace + * @property {number} centralityScore - Raw centrality score + * @property {string} riskLevel - Derived risk level (high, medium, low) + * @property {string} explanation - Human-readable explanation + */ + +/** + * @typedef {Object} RiskAnalysisResult + * @property {string} metric - Centrality metric used + * @property {RiskService[]} services - Services ranked by risk + * @property {Object} dataFreshness - Data freshness info + * @property {string} confidence - Confidence level (high, low) + */ + +/** + * Get top risk services based on centrality + * @param {Object} options + * @param {string} [options.metric='pagerank'] - Centrality metric + * @param {number} [options.limit=5] - Number of services to return + * @returns {Promise} + */ +async function getTopRiskServices({ metric = 'pagerank', limit = 5 } = {}) { + // Validate metric + const validMetrics = ['pagerank', 'betweenness']; + if (!validMetrics.includes(metric)) { + throw new Error(`Invalid metric: ${metric}. Allowed: ${validMetrics.join(', ')}`); + } + + // Fetch centrality data + const centralityResult = await getCentralityTop(metric, limit); + + if (!centralityResult.ok) { + throw new Error(`Failed to fetch centrality data: ${centralityResult.error}`); + } + + // Fetch freshness data + const healthResult = await checkGraphHealth(); + + let dataFreshness = null; + let confidence = 'unknown'; + + if (healthResult.ok) { + dataFreshness = { + source: 'graph-engine', + stale: healthResult.data.stale, + lastUpdatedSecondsAgo: healthResult.data.lastUpdatedSecondsAgo, + windowMinutes: healthResult.data.windowMinutes + }; + confidence = healthResult.data.stale ? 'low' : 'high'; + } + + // Transform centrality data to risk services + const topServices = centralityResult.data.top || []; + const total = topServices.length; + + const services = topServices.map((item, rank) => { + const rawServiceName = item.service; + const score = item.value || 0; + const riskLevel = determineRiskLevel(score, rank, total); + + // Parse namespace:name format if present, else default to "default" namespace + const { serviceId, name, namespace } = parseServiceIdentifier(rawServiceName); + + return { + serviceId, + name, + namespace, + centralityScore: score, + riskLevel, + explanation: generateExplanation(name, metric, score, riskLevel) + }; + }); + + return { + metric, + services, + dataFreshness, + confidence + }; +} + +/** + * Parse service identifier - supports "namespace:name" format or plain name + * @param {string} rawServiceName - Service name from Graph API + * @returns {{serviceId: string, name: string, namespace: string}} + */ +function parseServiceIdentifier(rawServiceName) { + if (rawServiceName.includes(':')) { + const colonIndex = rawServiceName.indexOf(':'); + const namespace = rawServiceName.substring(0, colonIndex); + const name = rawServiceName.substring(colonIndex + 1); + return { serviceId: rawServiceName, name, namespace }; + } + return { + serviceId: `default:${rawServiceName}`, + name: rawServiceName, + namespace: 'default' + }; +} + +module.exports = { + getTopRiskServices, + // Exported for testing + _test: { + determineRiskLevel, + generateExplanation, + parseServiceIdentifier, + RISK_THRESHOLDS + } +}; diff --git a/src/simulation/scalingSimulation.js b/src/simulation/scalingSimulation.js new file mode 100644 index 0000000..d7379e6 --- /dev/null +++ b/src/simulation/scalingSimulation.js @@ -0,0 +1,535 @@ +const { getProvider } = require('../storage/providers'); +const { findTopPathsToTarget } = require('./pathAnalysis'); +const { generateScalingRecommendations } = require('../utils/recommendations'); +const { createTrace } = require('../utils/trace'); +const config = require('../config/config'); +const telemetryService = require('../services/telemetryService'); + +/** + * @typedef {import('./providers/GraphDataProvider').EdgeData} EdgeData + * @typedef {import('./providers/GraphDataProvider').GraphSnapshot} GraphSnapshot + */ + +/** + * @typedef {Object} ScalingModel + * @property {string} type - Model type (bounded_sqrt, linear) + * @property {number} [alpha] - Fixed overhead fraction (0.0-1.0) + */ + +/** + * @typedef {Object} ScalingSimulationRequest + * @property {string} serviceId - Target service ID + * @property {number} currentPods - Current pod count + * @property {number} newPods - New pod count + * @property {string} [latencyMetric] - Latency metric to use (p50, p95, p99) + * @property {ScalingModel} [model] - Scaling model configuration + * @property {number} [maxDepth] - Maximum traversal depth + * @property {string} [timeWindow] - Historical time window (e.g. '1w') + */ + +/** + * @typedef {Object} AffectedCallerScaling + * @property {string} serviceId - Caller service ID + * @property {number|null} beforeMs - Weighted mean latency before scaling (null if no traffic) + * @property {number|null} afterMs - Weighted mean latency after scaling (null if no traffic) + * @property {number|null} deltaMs - Latency change (negative = improvement) + */ + +/** + * @typedef {Object} AffectedPath + * @property {string[]} path - Array of service IDs in path + * @property {number} beforeMs - Path latency before scaling + * @property {number} afterMs - Path latency after scaling + * @property {number} deltaMs - Latency change + */ + +/** + * @typedef {Object} ScalingSimulationResult + * @property {Object} target - Target service info + * @property {string} latencyMetric - Latency metric used + * @property {number} currentPods - Current pod count + * @property {number} newPods - New pod count + * @property {AffectedCallerScaling[]} affectedCallers - Callers with latency changes + * @property {AffectedPath[]} affectedPaths - Top N paths with latency changes + */ + +/** + * Apply bounded square root scaling formula + * Formula: newLatency = baseLatency * (alpha + (1 - alpha) * (1 / sqrt(ratio))) + * Clamped to minimum = baseLatency * minLatencyFactor + * + * @param {number} baseLatency - Current latency (ms) + * @param {number} currentPods - Current pod count + * @param {number} newPods - New pod count + * @param {number} alpha - Fixed overhead fraction (0.0-1.0) + * @returns {number} - New latency (ms) + */ +function applyBoundedSqrtScaling(baseLatency, currentPods, newPods, alpha) { + const ratio = newPods / currentPods; + const improvement = 1 / Math.sqrt(ratio); + const newLatency = baseLatency * (alpha + (1 - alpha) * improvement); + + // Clamp to minimum (can't improve beyond 60% of baseline by default) + const minLatency = baseLatency * config.simulation.minLatencyFactor; + return Math.max(newLatency, minLatency); +} + +/** + * Apply linear scaling formula + * Formula: newLatency = baseLatency * (currentPods / newPods) + * + * @param {number} baseLatency - Current latency (ms) + * @param {number} currentPods - Current pod count + * @param {number} newPods - New pod count + * @returns {number} - New latency (ms) + */ +function applyLinearScaling(baseLatency, currentPods, newPods) { + return baseLatency * (currentPods / newPods); +} + +/** + * Compute minimum hop distance from source to target using BFS + * Returns null if no path exists + * + * @param {GraphSnapshot} snapshot - Graph snapshot + * @param {string} sourceId - Source service ID + * @param {string} targetId - Target service ID + * @returns {number|null} - Hop distance or null + */ +function computeHopDistance(snapshot, sourceId, targetId) { + if (sourceId === targetId) return 0; + + const visited = new Set([sourceId]); + const queue = [{ id: sourceId, dist: 0 }]; + + while (queue.length > 0) { + const { id, dist } = queue.shift(); + const edges = snapshot.outgoingEdges.get(id) || []; + + for (const edge of edges) { + if (edge.target === targetId) { + return dist + 1; + } + if (!visited.has(edge.target)) { + visited.add(edge.target); + queue.push({ id: edge.target, dist: dist + 1 }); + } + } + } + + return null; // No path found +} + +/** + * Compute weighted mean latency for a service's outgoing calls + * Formula: SUM(rate * latency) / SUM(rate) + * Returns null if total rate is 0 (no traffic) OR if any latency is missing + * + * @param {EdgeData[]} edges - Outgoing edges + * @param {string} metric - Latency metric (p50, p95, p99) + * @param {Map} [adjustedLatencies] - Optional adjusted latencies for specific targets + * @returns {number|null} - Weighted mean latency in ms, or null if incomplete data + */ +function computeWeightedMeanLatency(edges, metric, adjustedLatencies = new Map()) { + let totalWeightedLatency = 0; + let totalRate = 0; + + for (const edge of edges) { + const rate = edge.rate ?? 0; + const latency = adjustedLatencies.has(edge.target) + ? adjustedLatencies.get(edge.target) + : edge[metric]; + + // Skip zero-rate edges + if (rate <= 0) continue; + + // If any required latency is missing, can't compute honestly + if (latency === null || latency === undefined) { + return null; + } + + totalWeightedLatency += rate * latency; + totalRate += rate; + } + + // Handle zero traffic case + if (totalRate === 0) { + return null; + } + + return totalWeightedLatency / totalRate; +} + +/** + * Simulate scaling of a service (increase/decrease pod count) + * Adjusts latencies on incoming edges to target, propagates upstream + * + * Algorithm: + * 1. Fetch k-hop upstream neighborhood + * 2. For each incoming edge to target, apply scaling formula (in-memory) + * 3. For each caller, compute weighted mean latency before/after + * 4. Compute path latencies (sum of edges along path) + * 5. Return top N paths by traffic volume + * + * @param {ScalingSimulationRequest} request - Simulation request + * @param {Object} options - Optional parameters (traceOptions, trace, correlationId) + * @returns {Promise} + */ +async function simulateScaling(request, options = {}) { + const maxDepth = request.maxDepth || config.simulation.maxTraversalDepth; + const latencyMetric = request.latencyMetric || config.simulation.defaultLatencyMetric; + const modelType = request.model?.type || config.simulation.scalingModel; + const alpha = request.model?.alpha ?? config.simulation.scalingAlpha; + const trace = options.trace || createTrace(options.traceOptions || {}); + const timeWindow = request.timeWindow; + + // Validate inputs + if (!Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > 3) { + throw new Error(`maxDepth must be integer 1, 2, or 3. Got: ${maxDepth}`); + } + if (!['p50', 'p95', 'p99'].includes(latencyMetric)) { + throw new Error(`Invalid latencyMetric: ${latencyMetric}`); + } + if (!Number.isInteger(request.currentPods) || !Number.isInteger(request.newPods)) { + throw new Error('currentPods and newPods must be integers'); + } + if (request.currentPods <= 0 || request.newPods <= 0) { + throw new Error('currentPods and newPods must be positive'); + } + if (alpha < 0 || alpha > 1) { + throw new Error('alpha must be between 0 and 1'); + } + + // Fetch upstream neighborhood via Graph Engine + const provider = getProvider(); + const snapshot = await provider.fetchUpstreamNeighborhood(request.serviceId, maxDepth, { trace }); + + // Overlay historical telemetry if requested + if (timeWindow) { + try { + const { from, to } = telemetryService.parseTimeWindow(timeWindow); + const aggregatedMetrics = await telemetryService.getAggregatedEdgeMetrics(from, to); + + if (aggregatedMetrics.size > 0) { + // Update edges in the snapshot + snapshot.edges.forEach(edge => { + const key = `${edge.source}->${edge.target}`; + const metrics = aggregatedMetrics.get(key); + if (metrics) { + edge.rate = metrics.requestRate; + edge.error_rate = metrics.errorRate; + edge.p50 = metrics.p50; + edge.p95 = metrics.p95; + edge.p99 = metrics.p99; + } else { + // If no historical data for this edge, assume zero traffic for simulation consistency + edge.rate = 0; + edge.error_rate = 0; + edge.p50 = 0; + edge.p95 = 0; + edge.p99 = 0; + } + }); + trace.log('Overlayed historical metrics', { timeWindow, metricsCount: aggregatedMetrics.size }); + } + } catch (err) { + console.error('Failed to overlay telemetry in simulateScaling:', err); + // Fallback to snapshot data + } + } + + // Use normalized target key from snapshot (handles namespace:name vs plain name difference) + const targetKey = snapshot.targetKey || request.serviceId; + + // Get target node info + const targetNode = snapshot.nodes.get(targetKey); + if (!targetNode) { + throw new Error(`Service not found: ${request.serviceId}`); + } + + // Apply scaling formula to target (compute ONCE using rate-weighted mean of incoming latencies) + const { adjustedLatencies, baseLatency, newLatency: projectedLatency } = await trace.stage('apply-scaling-model', async () => { + const latMap = new Map(); + const edges = snapshot.incomingEdges.get(targetKey) || []; + + // Compute rate-weighted mean baseline latency from incoming edges + let baseLat = null; + if (edges.length > 0) { + let totalWeighted = 0; + let totalRate = 0; + for (const edge of edges) { + const rate = edge.rate ?? 0; + const lat = edge[latencyMetric]; + if (rate > 0 && lat !== null && lat !== undefined) { + totalWeighted += rate * lat; + totalRate += rate; + } + } + if (totalRate > 0) { + baseLat = totalWeighted / totalRate; + } + } + + // Apply scaling model if we have baseline + let newLat = null; + if (baseLat !== null) { + if (modelType === 'bounded_sqrt') { + newLat = applyBoundedSqrtScaling( + baseLat, + request.currentPods, + request.newPods, + alpha + ); + } else if (modelType === 'linear') { + newLat = applyLinearScaling( + baseLat, + request.currentPods, + request.newPods + ); + } else { + throw new Error(`Unknown scaling model: ${modelType}`); + } + latMap.set(targetKey, newLat); + } + + return { adjustedLatencies: latMap, baseLatency: baseLat, newLatency: newLat }; + }); + + // Add apply-scaling-model summary to trace + trace.setSummary('apply-scaling-model', { + model: { type: modelType, alpha }, + currentPods: request.currentPods, + newPods: request.newPods, + latencyFactor: baseLatency && projectedLatency ? (projectedLatency / baseLatency).toFixed(2) : null + }); + + // Compute impact on ALL upstream nodes (not just direct callers) + // This shows true propagation through the dependency graph + const affectedCallers = []; + for (const [nodeId, nodeData] of snapshot.nodes) { + // Skip the target itself + if (nodeId === targetKey) continue; + + const nodeEdges = snapshot.outgoingEdges.get(nodeId) || []; + if (nodeEdges.length === 0) continue; + + const beforeMs = computeWeightedMeanLatency(nodeEdges, latencyMetric); + const afterMs = computeWeightedMeanLatency(nodeEdges, latencyMetric, adjustedLatencies); + + // Only include if there's actual impact (delta != 0) or measurable latency + const deltaMs = (beforeMs !== null && afterMs !== null) ? (afterMs - beforeMs) : null; + + affectedCallers.push({ + serviceId: nodeId, + name: nodeData?.name ?? nodeId.split(':')[1], + namespace: nodeData?.namespace ?? nodeId.split(':')[0], + hopDistance: computeHopDistance(snapshot, nodeId, targetKey), + beforeMs, + afterMs, + deltaMs + }); + } + + // Sort by absolute delta descending (biggest improvements first, nulls last) + affectedCallers.sort((a, b) => { + if (a.deltaMs === null) return 1; + if (b.deltaMs === null) return -1; + return Math.abs(b.deltaMs) - Math.abs(a.deltaMs); + }); + + // Compute real multi-hop paths using findTopPathsToTarget (inside trace stage) + const { affectedPaths } = await trace.stage('path-analysis', async () => { + const topPaths = findTopPathsToTarget( + snapshot, + targetKey, + maxDepth, + config.simulation.maxPathsReturned + ); + + // For each path, compute before/after latency (sum of edge latencies) + const paths = []; + for (const pathInfo of topPaths) { + const { path } = pathInfo; + let beforeMs = 0; + let afterMs = 0; + let hasIncompleteData = false; + + // Sum latencies along path edges + for (let i = 0; i < path.length - 1; i++) { + const source = path[i]; + const target = path[i + 1]; + const edges = snapshot.outgoingEdges.get(source) || []; + const edge = edges.find(e => e.target === target); + + if (!edge || edge[latencyMetric] === null || edge[latencyMetric] === undefined) { + hasIncompleteData = true; + break; + } + + const edgeLatency = edge[latencyMetric]; + beforeMs += edgeLatency; + + // Use adjusted latency if this edge points to target + if (target === targetKey && adjustedLatencies.has(target)) { + afterMs += adjustedLatencies.get(target); + } else { + afterMs += edgeLatency; + } + } + + paths.push({ + path, + pathRps: pathInfo.pathRps, + beforeMs: hasIncompleteData ? null : beforeMs, + afterMs: hasIncompleteData ? null : afterMs, + deltaMs: hasIncompleteData ? null : (afterMs - beforeMs), + incompleteData: hasIncompleteData + }); + } + + // Sort by absolute delta descending (null deltas last) + paths.sort((a, b) => { + if (a.deltaMs === null) return 1; + if (b.deltaMs === null) return -1; + return Math.abs(b.deltaMs) - Math.abs(a.deltaMs); + }); + + return { affectedPaths: paths }; + }); + + // Add path-analysis summary to trace + trace.setSummary('path-analysis', { + pathsFound: affectedPaths.length, + pathsReturned: affectedPaths.length + }); + + // Build path lookup: for each caller, find their best (highest pathRps) path to target + const callerBestPath = new Map(); + for (const pathObj of affectedPaths) { + const startNode = pathObj.path[0]; + if (!callerBestPath.has(startNode) || pathObj.pathRps > callerBestPath.get(startNode).pathRps) { + callerBestPath.set(startNode, pathObj); + } + } + + // Enrich affectedCallers with end-to-end latency from their best path + for (const caller of affectedCallers) { + const bestPath = callerBestPath.get(caller.serviceId); + if (bestPath && bestPath.deltaMs !== null) { + caller.endToEndBeforeMs = bestPath.beforeMs; + caller.endToEndAfterMs = bestPath.afterMs; + caller.endToEndDeltaMs = bestPath.deltaMs; + caller.viaPath = bestPath.path; + } else { + caller.endToEndBeforeMs = null; + caller.endToEndAfterMs = null; + caller.endToEndDeltaMs = null; + caller.viaPath = null; + } + } + + // Add compute-impact summary to trace + trace.setSummary('compute-impact', { + affectedCallersCount: affectedCallers.length, + affectedPathsCount: affectedPaths.length, + latencyDeltaSummary: baseLatency && projectedLatency ? { + before: Math.round(baseLatency * 100) / 100, + after: Math.round(projectedLatency * 100) / 100, + delta: Math.round((projectedLatency - baseLatency) * 100) / 100 + } : null + }); + + // Determine data confidence based on staleness + const dataFreshness = snapshot.dataFreshness ?? null; + const confidence = dataFreshness?.stale ? 'low' : 'high'; + + // Compute scaling direction + const scalingDirection = request.newPods > request.currentPods ? 'up' + : request.newPods < request.currentPods ? 'down' + : 'none'; + + // Build result object (without recommendations first) + const result = { + target: { + serviceId: targetNode.serviceId, + name: targetNode.name, + namespace: targetNode.namespace + }, + neighborhood: { + description: 'k-hop upstream subgraph around target (not full graph)', + serviceCount: snapshot.nodes.size, + edgeCount: snapshot.edges.length, + depthUsed: maxDepth, + generatedAt: new Date().toISOString() + }, + dataFreshness, + confidence, + latencyMetric, + scalingModel: { type: modelType, alpha }, + currentPods: request.currentPods, + newPods: request.newPods, + latencyEstimate: { + description: 'Rate-weighted mean of incoming edge latency to target', + baselineMs: baseLatency, + projectedMs: adjustedLatencies.get(targetKey) ?? null, + deltaMs: (baseLatency !== null && adjustedLatencies.has(targetKey)) + ? (adjustedLatencies.get(targetKey) - baseLatency) + : null, + unit: 'milliseconds' + }, + scalingDirection, + affectedCallers: { + description: 'Edge-level impact: deltaMs is change in this caller\'s direct outgoing edge latency. endToEndDeltaMs is cumulative path latency change.', + items: affectedCallers.slice(0, config.simulation.maxPathsReturned) + }, + affectedPaths + }; + + // Generate explanation string + const latencyInfo = result.latencyEstimate; + const callersCount = result.affectedCallers.items.length; + const pathsCount = result.affectedPaths.length; + const directionWord = scalingDirection === 'up' ? 'up' : scalingDirection === 'down' ? 'down' : 'at same level'; + + if (latencyInfo.baselineMs !== null && latencyInfo.projectedMs !== null) { + const improvementWord = latencyInfo.deltaMs < 0 ? 'improves' : latencyInfo.deltaMs > 0 ? 'degrades' : 'maintains'; + result.explanation = `Scaling ${targetNode.name} ${directionWord} from ${request.currentPods} to ${request.newPods} pods ` + + `${improvementWord} latency by ${Math.abs(latencyInfo.deltaMs).toFixed(1)}ms ` + + `(baseline: ${latencyInfo.baselineMs.toFixed(1)}ms → projected: ${latencyInfo.projectedMs.toFixed(1)}ms). ` + + `${callersCount} upstream caller(s) affected across ${pathsCount} path(s).`; + } else { + result.explanation = `Scaling ${targetNode.name} ${directionWord} from ${request.currentPods} to ${request.newPods} pods. ` + + `Latency impact unknown due to missing edge metrics. ` + + `${callersCount} upstream caller(s) identified across ${pathsCount} path(s).`; + } + + // Add warnings if any path has incomplete data + const incompletePathsCount = result.affectedPaths.filter(p => p.incompleteData).length; + if (incompletePathsCount > 0) { + result.warnings = [ + `${incompletePathsCount} of ${pathsCount} path(s) have incomplete latency data (missing edge metrics). Results may be partial.` + ]; + } + + // Generate recommendations based on result (inside trace stage) + result.recommendations = await trace.stage('recommendations', async () => { + return generateScalingRecommendations(result); + }); + + // Add recommendations summary to trace + trace.setSummary('recommendations', { + recommendationCount: result.recommendations.length + }); + + // Attach pipeline trace if enabled + const pipelineTrace = trace.finalize(); + if (pipelineTrace) { + result.pipelineTrace = pipelineTrace; + } + + return result; +} + +module.exports = { + simulateScaling +}; diff --git a/src/storage/decisionStore.js b/src/storage/decisionStore.js new file mode 100644 index 0000000..e29bba1 --- /dev/null +++ b/src/storage/decisionStore.js @@ -0,0 +1,169 @@ +/** + * SQLite Decision Store + * Stores decision logs from Pipeline Playground for audit trail and analysis + */ + +const Database = require('better-sqlite3'); +const fs = require('node:fs'); +const path = require('node:path'); +const config = require('../config/config'); + +class DecisionStore { + constructor(dbPath = config.sqlite.dbPath) { + this.dbPath = dbPath; + this.db = null; + this.init(); + } + + /** + * Initialize database connection and schema + */ + init() { + try { + // Ensure data directory exists + const dir = path.dirname(this.dbPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // Open database with WAL mode for better concurrency + this.db = new Database(this.dbPath); + this.db.pragma('journal_mode = WAL'); + + // Create schema if not exists + this.db.exec(` + CREATE TABLE IF NOT EXISTS decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + type TEXT NOT NULL, + scenario TEXT NOT NULL, + result TEXT NOT NULL, + correlation_id TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_decisions_timestamp ON decisions(timestamp DESC); + CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type); + CREATE INDEX IF NOT EXISTS idx_decisions_correlation_id ON decisions(correlation_id); + `); + + // Log absolute path for debugging (safe, no secrets) + const absolutePath = require('node:path').resolve(this.dbPath); + console.log(`[DecisionStore] Initialized at ${absolutePath}`); + } catch (error) { + console.error(`[DecisionStore] Initialization failed: ${error.message}`); + this.db = null; + } + } + + /** + * Log a decision + * @param {Object} decision - Decision data + * @param {string} decision.timestamp - ISO 8601 timestamp + * @param {string} decision.type - Decision type (failure, scaling, risk) + * @param {Object} decision.scenario - Scenario parameters + * @param {Object} decision.result - Simulation result + * @param {string} [decision.correlationId] - Optional correlation ID + * @returns {Object} Inserted record with id + */ + logDecision({ timestamp, type, scenario, result, correlationId }) { + if (!this.db) { + throw new Error('Database not initialized'); + } + + const stmt = this.db.prepare(` + INSERT INTO decisions (timestamp, type, scenario, result, correlation_id) + VALUES (?, ?, ?, ?, ?) + `); + + const info = stmt.run( + timestamp, + type, + JSON.stringify(scenario), + JSON.stringify(result), + correlationId || null + ); + + return { + id: info.lastInsertRowid, + timestamp + }; + } + + /** + * Get decision history with pagination and optional type filter + * @param {Object} options - Query options + * @param {number} [options.limit=50] - Page size (max 100) + * @param {number} [options.offset=0] - Pagination offset + * @param {string} [options.type] - Filter by decision type + * @returns {Array} Array of decision records + */ + getHistory({ limit = 50, offset = 0, type } = {}) { + if (!this.db) { + throw new Error('Database not initialized'); + } + + // Enforce limits + limit = Math.min(Math.max(1, limit), 100); + offset = Math.max(0, offset); + + let query = 'SELECT * FROM decisions'; + const params = []; + + if (type) { + query += ' WHERE type = ?'; + params.push(type); + } + + query += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + const stmt = this.db.prepare(query); + const rows = stmt.all(...params); + + return rows.map(row => ({ + id: row.id, + timestamp: row.timestamp, + type: row.type, + scenario: JSON.parse(row.scenario), + result: JSON.parse(row.result), + correlationId: row.correlation_id, + createdAt: row.created_at + })); + } + + /** + * Get total count of decisions (optionally filtered by type) + * @param {string} [type] - Optional type filter + * @returns {number} Total count + */ + getCount(type) { + if (!this.db) { + throw new Error('Database not initialized'); + } + + let query = 'SELECT COUNT(*) as count FROM decisions'; + const params = []; + + if (type) { + query += ' WHERE type = ?'; + params.push(type); + } + + const stmt = this.db.prepare(query); + const result = stmt.get(...params); + return result.count; + } + + /** + * Close database connection + */ + close() { + if (this.db) { + this.db.close(); + console.log('[DecisionStore] Database closed'); + } + } +} + +module.exports = DecisionStore; diff --git a/src/storage/decisionStoreSingleton.js b/src/storage/decisionStoreSingleton.js new file mode 100644 index 0000000..cea5748 --- /dev/null +++ b/src/storage/decisionStoreSingleton.js @@ -0,0 +1,45 @@ +/** + * DecisionStore Singleton + * Ensures only one DecisionStore instance across the application + */ + +const DecisionStore = require('./decisionStore'); +const config = require('../config/config'); + +let instance = null; + +/** + * Get or create the singleton DecisionStore instance + * @returns {DecisionStore|null} DecisionStore instance or null if initialization failed + */ +function getDecisionStore() { + if (!instance) { + try { + instance = new DecisionStore(config.sqlite.dbPath); + } catch (error) { + console.error(`[DecisionStoreSingleton] Failed to initialize: ${error.message}`); + return null; + } + } + return instance; +} + +/** + * Close the DecisionStore connection (for graceful shutdown) + */ +async function closeDecisionStore() { + if (instance && instance.db) { + try { + instance.db.close(); + console.log('[DecisionStore] Connection closed'); + instance = null; + } catch (error) { + console.error(`[DecisionStore] Error closing: ${error.message}`); + } + } +} + +module.exports = { + getDecisionStore, + closeDecisionStore +}; diff --git a/src/storage/providers/GraphDataProvider.js b/src/storage/providers/GraphDataProvider.js new file mode 100644 index 0000000..9c9f997 --- /dev/null +++ b/src/storage/providers/GraphDataProvider.js @@ -0,0 +1,74 @@ +/** + * GraphDataProvider Interface Contract (JSDoc only) + * + * All graph data providers must implement these methods. + */ + +/** + * @typedef {Object} NodeData + * @property {string} serviceId - Service identifier (plain name like "frontend") + * @property {string} name - Service name + * @property {string} [namespace] - Service namespace (optional, defaults to "default") + */ + +/** + * @typedef {Object} EdgeData + * @property {string} source - Source service ID + * @property {string} target - Target service ID + * @property {number} rate - Request rate (RPS) + * @property {number} errorRate - Error rate (RPS) + * @property {number} p50 - P50 latency (ms) + * @property {number} p95 - P95 latency (ms) + * @property {number} p99 - P99 latency (ms) + */ + +/** + * @typedef {Object} DataFreshness + * @property {string} source - Data source (always 'graph-engine') + * @property {boolean} stale - Whether data is stale + * @property {number|null} lastUpdatedSecondsAgo - Seconds since last update + * @property {number|null} [windowMinutes] - Aggregation window in minutes + */ + +/** + * @typedef {Object} GraphSnapshot + * @property {Map} nodes - Map of serviceId to node data + * @property {EdgeData[]} edges - Array of all edges + * @property {Map} incomingEdges - Map of target serviceId to incoming edges + * @property {Map} outgoingEdges - Map of source serviceId to outgoing edges + * @property {string} [targetKey] - Provider-normalized identifier used as the key in nodes/edges maps. + * Graph Engine uses plain service names (e.g., "checkoutservice"). + * Simulations should use this for all map lookups instead of request.serviceId. + * @property {DataFreshness} [dataFreshness] - Data freshness metadata for simulation responses + */ + +/** + * @typedef {Object} HealthResult + * @property {boolean} connected - Whether the data source is connected + * @property {number} [services] - Number of services (optional) + * @property {boolean} [stale] - Whether data is stale (Graph API only) + * @property {number} [lastUpdatedSecondsAgo] - Seconds since last update (Graph API only) + * @property {string} [error] - Error message if not connected + */ + +/** + * GraphDataProvider Contract + * + * Implementations must provide: + * + * @method fetchUpstreamNeighborhood + * @param {string} targetServiceId - Target service ID + * @param {number} maxDepth - Maximum traversal depth (1-3) + * @returns {Promise} + * + * @method checkHealth + * @returns {Promise} + * + * @method close + * @returns {Promise} + */ + +module.exports = { + // This module only exports JSDoc types for documentation + // No runtime code - just contract documentation +}; diff --git a/src/storage/providers/GraphEngineHttpProvider.js b/src/storage/providers/GraphEngineHttpProvider.js new file mode 100644 index 0000000..11f0eeb --- /dev/null +++ b/src/storage/providers/GraphEngineHttpProvider.js @@ -0,0 +1,311 @@ +/** + * Graph Engine HTTP Provider + * + * Fetches graph data from the service-graph-engine HTTP API. + * Implements the GraphDataProvider interface. + * + * Uses /neighborhood endpoint (single call) instead of N+1 /peers calls. + */ + +const config = require('../../config/config'); +const { checkGraphHealth, getNeighborhood } = require('../../clients/graphEngineClient'); + +/** + * @typedef {import('./GraphDataProvider').GraphSnapshot} GraphSnapshot + * @typedef {import('./GraphDataProvider').EdgeData} EdgeData + * @typedef {import('./GraphDataProvider').NodeData} NodeData + * @typedef {import('./GraphDataProvider').HealthResult} HealthResult + */ + +/** + * Normalize service ID to plain name for Graph Engine API + * Input may be "namespace:name" or plain "name" + * Graph Engine uses plain names like "frontend", "checkoutservice" + * + * TODO: Graph Engine assumes unique service names across namespaces. + * If multiple namespaces exist, this will need enhancement. + * + * @param {string} serviceId + * @returns {string} Plain service name + */ +function normalizeServiceName(serviceId) { + // If format is "namespace:name", extract just the name + if (serviceId.includes(':')) { + return serviceId.split(':').pop(); + } + return serviceId; +} + +/** + * Merge two edge metrics for deduplicating edges with same (from, to) + * + * Merge rules: + * - rate: SUM (total traffic across duplicates) + * - errorRate: rate-weighted average (fallback to max if total rate is 0) + * - p50/p95/p99: MAX (conservative - worst-case latency) + * + * @param {EdgeData} a - First edge + * @param {EdgeData} b - Second edge + * @returns {{rate: number, errorRate: number, p50: number, p95: number, p99: number}} + */ +function mergeEdgeMetrics(a, b) { + const r1 = a.rate ?? 0; + const r2 = b.rate ?? 0; + const total = r1 + r2; + + const e1 = a.errorRate ?? 0; + const e2 = b.errorRate ?? 0; + + return { + rate: total, + errorRate: total > 0 ? ((e1 * r1) + (e2 * r2)) / total : Math.max(e1, e2), + p50: Math.max(a.p50 ?? 0, b.p50 ?? 0), + p95: Math.max(a.p95 ?? 0, b.p95 ?? 0), + p99: Math.max(a.p99 ?? 0, b.p99 ?? 0), + }; +} + +class GraphEngineHttpProvider { + // No persistent state needed for HTTP provider + + /** + * Check staleness and return freshness metadata + * @private + * @param {Object} trace - Optional trace instance + * @returns {Promise<{stale: boolean, lastUpdatedSecondsAgo: number|null, windowMinutes: number}>} + * @throws {Error} If unavailable (503) or stale and required=true (503) + */ + async _checkStaleness(trace = null) { + const executeCheck = async () => { + return await checkGraphHealth(); + }; + + const healthResult = trace && trace.stage + ? await trace.stage('staleness-check', executeCheck) + : await executeCheck(); + + if (!healthResult.ok) { + const err = new Error(`Graph API unavailable: ${healthResult.error}`); + err.statusCode = 503; + throw err; + } + + const { stale, lastUpdatedSecondsAgo, windowMinutes } = healthResult.data; + + // Add staleness summary to trace + if (trace && trace.setSummary) { + trace.setSummary('staleness-check', { + stale, + lastUpdatedSecondsAgo, + windowMinutes + }); + } + + if (stale) { + const staleAge = lastUpdatedSecondsAgo === null ? 'age unknown' : `${lastUpdatedSecondsAgo}s old`; + const err = new Error( + `Graph data is stale (${staleAge}). Simulation aborted.` + ); + err.statusCode = 503; + throw err; + } + + // Return freshness metadata for inclusion in snapshot + return { stale, lastUpdatedSecondsAgo, windowMinutes }; + } + + /** + * Fetch k-hop neighborhood using Graph Engine HTTP API + * + * Algorithm: + * 1. Check staleness via /graph/health (returns freshness metadata) + * 2. GET /services/{target}/neighborhood?k=K -> { nodes[], edges[] } + * 3. Build nodes Map from nodes array + * 4. Build edges from edges array, deduping by (from,to) key with merge + * 5. Build adjacency maps (incomingEdges, outgoingEdges) + * + * @param {string} targetServiceId - Target service ID (may be "namespace:name" or plain "name") + * @param {number} maxDepth - Maximum traversal depth (1-3) + * @param {Object} options - Optional parameters (trace) + * @returns {Promise} + */ + async fetchUpstreamNeighborhood(targetServiceId, maxDepth, options = {}) { + const trace = options.trace || null; + // Validate depth + if (maxDepth < 1 || maxDepth > 3 || !Number.isInteger(maxDepth)) { + throw new Error(`Invalid maxDepth: ${maxDepth}. Must be 1, 2, or 3`); + } + + // Normalize service ID: extract plain name from "namespace:name" format + const serviceName = normalizeServiceName(targetServiceId); + + // Step 1: Check staleness + get freshness metadata + const freshness = await this._checkStaleness(trace); + + // Step 2: Get neighborhood (nodes + edges in single call) + const fetchNeighborhood = async () => { + return await getNeighborhood(serviceName, maxDepth); + }; + + const neighborhoodResult = trace && trace.stage + ? await trace.stage('fetch-neighborhood', fetchNeighborhood) + : await fetchNeighborhood(); + + if (!neighborhoodResult.ok) { + if (neighborhoodResult.status === 404) { + throw new Error(`Service not found: ${targetServiceId}`); + } + throw new Error(`Failed to fetch neighborhood: ${neighborhoodResult.error}`); + } + + const nodeObjects = neighborhoodResult.data.nodes || []; + + if (nodeObjects.length === 0) { + throw new Error(`Service not found: ${targetServiceId}`); + } + + const nodeSet = new Set(nodeObjects.map(n => n.name)); + const rawEdgesCount = (neighborhoodResult.data.edges || []).length; + + // Add fetch summary to trace + if (trace && trace.setSummary) { + trace.setSummary('fetch-neighborhood', { + depthUsed: maxDepth, + nodesReturned: nodeObjects.length, + edgesReturned: rawEdgesCount + }); + } + + // Build nodes Map from node objects + /** @type {Map} */ + const nodes = new Map(); + for (const nodeObj of nodeObjects) { + nodes.set(nodeObj.name, { + serviceId: nodeObj.name, + name: nodeObj.name, + namespace: nodeObj.namespace || 'default', + podCount: nodeObj.podCount, + availability: nodeObj.availability + }); + } + + // Step 3: Build edges from /neighborhood.edges (dedupe by from->to) + const rawEdges = neighborhoodResult.data.edges || []; + + /** @type {Map} */ + const edgeMap = new Map(); + + for (const e of rawEdges) { + const from = e.from; + const to = e.to; + + // Skip malformed or out-of-neighborhood edges + if (!from || !to) continue; + if (!nodeSet.has(from) || !nodeSet.has(to)) continue; + + const edgeKey = `${from}->${to}`; + + const candidate = { + source: from, + target: to, + rate: e.rate ?? 0, + errorRate: e.errorRate ?? 0, + p50: e.p50 ?? 0, + p95: e.p95 ?? 0, + p99: e.p99 ?? 0 + }; + + const existing = edgeMap.get(edgeKey); + if (!existing) { + edgeMap.set(edgeKey, candidate); + } else { + // Merge: sum rates, weighted errorRate, max latencies + const merged = mergeEdgeMetrics(existing, candidate); + edgeMap.set(edgeKey, { source: from, target: to, ...merged }); + } + } + + // Step 4: Build edges array and adjacency maps + const edges = Array.from(edgeMap.values()); + + /** @type {Map} */ + const incomingEdges = new Map(); + /** @type {Map} */ + const outgoingEdges = new Map(); + + // Initialize empty arrays for all nodes + for (const nodeObj of nodeObjects) { + incomingEdges.set(nodeObj.name, []); + outgoingEdges.set(nodeObj.name, []); + } + + // Populate adjacency maps + for (const edge of edges) { + // Safety guard for unexpected edge endpoints + if (!incomingEdges.has(edge.target)) { + incomingEdges.set(edge.target, []); + } + if (!outgoingEdges.has(edge.source)) { + outgoingEdges.set(edge.source, []); + } + + incomingEdges.get(edge.target).push(edge); + outgoingEdges.get(edge.source).push(edge); + } + + // Add build-snapshot summary to trace + if (trace && trace.setSummary) { + trace.setSummary('build-snapshot', { + serviceCount: nodes.size, + edgeCount: edges.length + }); + } + + return { + nodes, + edges, + incomingEdges, + outgoingEdges, + // Normalized target key for lookups (plain service name in API mode) + targetKey: serviceName, + // Data freshness metadata for simulation responses + dataFreshness: { + source: 'graph-engine', + stale: freshness.stale, + lastUpdatedSecondsAgo: freshness.lastUpdatedSecondsAgo, + windowMinutes: freshness.windowMinutes + } + }; + } + + /** + * Check Graph Engine health + * @returns {Promise} + */ + async checkHealth() { + const result = await checkGraphHealth(); + + if (result.ok) { + return { + connected: true, + stale: result.data.stale, + lastUpdatedSecondsAgo: result.data.lastUpdatedSecondsAgo + }; + } else { + return { + connected: false, + error: result.error + }; + } + } + + /** + * Close provider (no-op for HTTP provider) + * @returns {Promise} + */ + async close() { + // No persistent connections to close for HTTP provider + } +} + +module.exports = { GraphEngineHttpProvider, mergeEdgeMetrics, normalizeServiceName }; diff --git a/src/storage/providers/index.js b/src/storage/providers/index.js new file mode 100644 index 0000000..128da81 --- /dev/null +++ b/src/storage/providers/index.js @@ -0,0 +1,36 @@ +/** + * Provider Factory - Graph Engine Only + * + * Returns GraphEngineHttpProvider as the single source of truth. + * Uses singleton pattern to avoid multiple provider instances. + */ + +const { GraphEngineHttpProvider } = require('./GraphEngineHttpProvider'); + +/** @type {import('./GraphEngineHttpProvider').GraphEngineHttpProvider | null} */ +let _provider = null; + +/** + * Get the Graph Engine HTTP provider (singleton) + * + * Graph Engine is the only data source - no fallback logic. + * + * @returns {import('./GraphEngineHttpProvider').GraphEngineHttpProvider} + */ +function getProvider() { + if (_provider) { + return _provider; + } + + _provider = new GraphEngineHttpProvider(); + return _provider; +} + +/** + * Reset provider singleton (for testing) + */ +function resetProvider() { + _provider = null; +} + +module.exports = { getProvider, resetProvider }; diff --git a/src/telemetry/pollWorker.js b/src/telemetry/pollWorker.js new file mode 100644 index 0000000..787bf4b --- /dev/null +++ b/src/telemetry/pollWorker.js @@ -0,0 +1,206 @@ +/** + * Background Poll Worker + * Polls Graph Engine API and writes metrics to InfluxDB + */ + +const graphEngineClient = require('../clients/graphEngineClient'); +const InfluxWriter = require('../clients/influxWriter'); +const config = require('../config/config'); + +class PollWorker { + constructor() { + this.influxWriter = new InfluxWriter(); + this.intervalId = null; + this.isRunning = false; + this.polling = false; + this.lastPollAt = null; + this.lastSuccessAt = null; + } + + /** + * Start the poll worker + */ + start() { + if (!config.telemetryWorker.enabled) { + console.log('[PollWorker] Disabled (TELEMETRY_WORKER_ENABLED=false)'); + return; + } + + if (this.isRunning) { + console.warn('[PollWorker] Already running'); + return; + } + + console.log(`[PollWorker] Starting with ${config.telemetryWorker.pollIntervalMs}ms interval`); + this.isRunning = true; + + // Run immediately on start + this.poll(); + + // Schedule recurring polls + this.intervalId = setInterval(() => { + this.poll(); + }, config.telemetryWorker.pollIntervalMs); + } + + /** + * Stop the poll worker + */ + async stop() { + if (!this.isRunning) { + return; + } + + console.log('[PollWorker] Stopping...'); + this.isRunning = false; + + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + await this.influxWriter.close(); + console.log('[PollWorker] Stopped'); + } + + /** + * Execute one poll cycle + */ + async poll() { + // Overlap protection - skip if previous poll still running + if (this.polling) { + console.warn('[PollWorker] Previous poll still running, skipping this cycle'); + return; + } + + this.polling = true; + this.lastPollAt = new Date(); + + try { + console.log('[PollWorker] Polling Graph Engine...'); + + // 1. Fetch Request/Latency Metrics (via Snapshot) + let services = []; + let edges = []; + + try { + const snapshotResult = await graphEngineClient.getMetricsSnapshot(); + + if (snapshotResult.ok && snapshotResult.data) { + // Transform Graph Engine schema to InfluxDB schema + services = (snapshotResult.data.services || []).map(svc => { + const hasTraffic = svc.rps && svc.rps > 0; + return { + name: svc.name, + namespace: svc.namespace, + requestRate: svc.rps ?? null, + errorRate: hasTraffic ? svc.errorRate : null, + p50: null, + p95: hasTraffic ? svc.p95 : null, + p99: null, + availability: null + }; + }); + + edges = (snapshotResult.data.edges || []).map(edge => { + const hasTraffic = edge.rps && edge.rps > 0; + return { + from: edge.from, + to: edge.to, + namespace: edge.namespace, + requestRate: edge.rps ?? null, + errorRate: hasTraffic ? edge.errorRate : null, + p50: null, + p95: hasTraffic ? edge.p95 : null, + p99: null + }; + }); + } + } catch (err) { + console.error(`[PollWorker] Snapshot fetch failed: ${err.message}`); + } + + // 2. Fetch Infrastructure Metrics (via Services with Placement) + // This is a separate call because snapshot is optimized for light edges + let infraData = { nodes: [], services: [] }; + try { + const servicesResult = await graphEngineClient.getServicesWithPlacement(); + if (servicesResult.ok && servicesResult.data && servicesResult.data.services) { + // Extract Nodes from the services list (Graph Engine returns services -> placement -> nodes) + // We need to de-duplicate nodes since multiple services run on same nodes + const nodeMap = new Map(); + + servicesResult.data.services.forEach(svc => { + if (svc.placement && svc.placement.nodes) { + svc.placement.nodes.forEach(node => { + if (!node.node) return; + if (!nodeMap.has(node.node)) { + nodeMap.set(node.node, node); + } else { + // Merge pods + const existing = nodeMap.get(node.node); + if (node.pods && node.pods.length > 0) { + // Add pods that aren't already listed (simple check by name) + node.pods.forEach(p => { + if (!existing.pods.some(ep => ep.name === p.name)) { + existing.pods.push(p); + } + }); + } + } + }); + } + }); + + infraData.nodes = Array.from(nodeMap.values()); + } + } catch (err) { + console.error(`[PollWorker] Infra fetch failed: ${err.message}`); + } + + // 3. Write to InfluxDB + const promises = []; + + if (services.length > 0) { + promises.push(this.influxWriter.writeServiceMetrics(services)); + } + + if (edges.length > 0) { + promises.push(this.influxWriter.writeEdgeMetrics(edges)); + } + + if (infraData.nodes.length > 0) { + promises.push(this.influxWriter.writeInfrastructureMetrics(infraData)); + } + + await Promise.all(promises); + + this.lastSuccessAt = new Date(); + console.log(`[PollWorker] Poll complete: ${services.length} services, ${edges.length} edges, ${infraData.nodes.length} nodes`); + + } catch (error) { + console.error(`[PollWorker] Poll failed: ${error.message}`); + // Continue running despite errors + } finally { + this.polling = false; + } + } +} + +// Singleton instance +let workerInstance = null; + +/** + * Get or create the singleton poll worker instance + */ +function getWorker() { + if (!workerInstance) { + workerInstance = new PollWorker(); + } + return workerInstance; +} + +module.exports = { + getWorker, + PollWorker +}; diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..a525287 --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,85 @@ +/** + * Structured JSON Logger + * + * Minimal logger that outputs JSON-formatted log lines for structured logging. + * Compatible with log aggregators (ELK, Loki, CloudWatch, etc.) + */ + +const LOG_LEVELS = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; + +const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL] || LOG_LEVELS.info; + +/** + * Format and output a log entry as JSON + * @param {string} level - Log level + * @param {string} message - Log message + * @param {Object} [context] - Additional context fields + */ +function log(level, message, context = {}) { + if (LOG_LEVELS[level] < currentLevel) return; + + const entry = { + timestamp: new Date().toISOString(), + level, + message, + ...context + }; + + // Remove undefined values + Object.keys(entry).forEach(key => { + if (entry[key] === undefined) delete entry[key]; + }); + + const output = level === 'error' ? console.error : console.log; + output(JSON.stringify(entry)); +} + +/** + * Log info message + * @param {string} message + * @param {Object} [context] + */ +function info(message, context) { + log('info', message, context); +} + +/** + * Log warning message + * @param {string} message + * @param {Object} [context] + */ +function warn(message, context) { + log('warn', message, context); +} + +/** + * Log error message + * @param {string} message + * @param {Object} [context] + */ +function error(message, context) { + log('error', message, context); +} + +/** + * Log debug message + * @param {string} message + * @param {Object} [context] + */ +function debug(message, context) { + log('debug', message, context); +} + +module.exports = { + info, + warn, + error, + debug, + log, + LOG_LEVELS +}; diff --git a/src/utils/recommendations.js b/src/utils/recommendations.js new file mode 100644 index 0000000..a5dadac --- /dev/null +++ b/src/utils/recommendations.js @@ -0,0 +1,262 @@ +/** + * Recommendation Engine + * + * Generates actionable recommendations based on simulation results. + * Rules are confidence-aware and threshold-based. + */ + +/** + * Traffic loss thresholds for recommendations + */ +const TRAFFIC_THRESHOLDS = { + critical: 100, // RPS - very high impact + high: 50, // RPS - significant impact + medium: 10 // RPS - moderate impact +}; + +/** + * Latency change thresholds for recommendations (ms) + */ +const LATENCY_THRESHOLDS = { + significant: 50, // ms - very noticeable + moderate: 20, // ms - somewhat noticeable + minor: 5 // ms - barely noticeable +}; + +/** + * @typedef {Object} Recommendation + * @property {string} type - Recommendation type (circuit-breaker, redundancy, scaling, monitoring, etc.) + * @property {string} priority - Priority level (critical, high, medium, low) + * @property {string} target - Target service or component + * @property {string} reason - Why this recommendation is made + * @property {string} action - Suggested action + */ + +/** + * Generate recommendations for failure simulation results + * + * @param {Object} result - Failure simulation result + * @returns {Recommendation[]} + */ +function generateFailureRecommendations(result) { + const recommendations = []; + const confidence = result.confidence || 'unknown'; + + // Add confidence warning if data is stale + if (confidence === 'low') { + recommendations.push({ + type: 'data-quality', + priority: 'high', + target: 'graph-data', + reason: 'Graph data is stale (>5 minutes old)', + action: 'Verify graph-engine is syncing properly before acting on predictions' + }); + } + + const totalLost = result.totalLostTrafficRps || 0; + const affectedCallers = result.affectedCallers || []; + const unreachableServices = result.unreachableServices || []; + const affectedDownstream = result.affectedDownstream || []; + const targetName = result.target?.name || 'unknown'; + + // Critical impact - high traffic loss + if (totalLost >= TRAFFIC_THRESHOLDS.critical) { + recommendations.push({ + type: 'circuit-breaker', + priority: 'critical', + target: targetName, + reason: `Failure would cause ${totalLost.toFixed(1)} RPS total traffic loss`, + action: `Implement circuit breaker with fallback for all callers of ${targetName}` + }); + } + + // Multiple callers affected - need resilience + if (affectedCallers.length >= 3) { + const topCaller = affectedCallers[0]; + recommendations.push({ + type: 'redundancy', + priority: 'high', + target: targetName, + reason: `${affectedCallers.length} upstream services depend on ${targetName}`, + action: `Deploy ${targetName} across multiple availability zones` + }); + } + + // High-traffic callers need circuit breakers + for (const caller of affectedCallers) { + if (caller.lostTrafficRps >= TRAFFIC_THRESHOLDS.high) { + recommendations.push({ + type: 'circuit-breaker', + priority: 'high', + target: caller.name || caller.serviceId, + reason: `${caller.name || caller.serviceId} would lose ${caller.lostTrafficRps.toFixed(1)} RPS`, + action: `Add circuit breaker in ${caller.name} when calling ${targetName}` + }); + } + } + + // Unreachable services - cascading failure risk + if (unreachableServices.length > 0) { + const totalUnreachableLoss = unreachableServices.reduce( + (sum, s) => sum + (s.lostTrafficRps || 0), 0 + ); + + if (unreachableServices.length >= 2 || totalUnreachableLoss >= TRAFFIC_THRESHOLDS.medium) { + recommendations.push({ + type: 'topology-review', + priority: 'medium', + target: targetName, + reason: `${unreachableServices.length} service(s) become unreachable (cascade risk)`, + action: `Review dependency graph; consider alternative paths for: ${unreachableServices.slice(0, 3).map(s => s.name).join(', ')}` + }); + } + } + + // Downstream impact + if (affectedDownstream.length > 0) { + const totalDownstreamLoss = affectedDownstream.reduce( + (sum, s) => sum + (s.lostTrafficRps || 0), 0 + ); + + if (totalDownstreamLoss >= TRAFFIC_THRESHOLDS.medium) { + recommendations.push({ + type: 'graceful-degradation', + priority: 'medium', + target: targetName, + reason: `Downstream services lose ${totalDownstreamLoss.toFixed(1)} RPS from ${targetName}`, + action: `Implement graceful degradation in ${targetName} to reduce downstream blast radius` + }); + } + } + + // No significant impact - still recommend monitoring + if (recommendations.length === 0 || + (recommendations.length === 1 && recommendations[0].type === 'data-quality')) { + recommendations.push({ + type: 'monitoring', + priority: 'low', + target: targetName, + reason: 'Low predicted impact, but failures can still occur', + action: `Ensure alerting is configured for ${targetName} availability` + }); + } + + return recommendations; +} + +/** + * Generate recommendations for scaling simulation results + * + * @param {Object} result - Scaling simulation result + * @returns {Recommendation[]} + */ +function generateScalingRecommendations(result) { + const recommendations = []; + const confidence = result.confidence || 'unknown'; + + // Add confidence warning if data is stale + if (confidence === 'low') { + recommendations.push({ + type: 'data-quality', + priority: 'high', + target: 'graph-data', + reason: 'Graph data is stale (>5 minutes old)', + action: 'Verify graph-engine is syncing properly before acting on predictions' + }); + } + + const targetName = result.target?.name || 'unknown'; + const latencyEstimate = result.latencyEstimate || {}; + const deltaMs = latencyEstimate.deltaMs; + const currentPods = result.currentPods || 1; + const newPods = result.newPods || 1; + const scalingUp = newPods > currentPods; + const affectedCallers = result.affectedCallers?.items || []; + + // Scaling down with negative delta (latency increase) + if (!scalingUp && deltaMs !== null && deltaMs > 0) { + if (deltaMs >= LATENCY_THRESHOLDS.significant) { + recommendations.push({ + type: 'scaling-caution', + priority: 'critical', + target: targetName, + reason: `Scaling down may increase latency by ${deltaMs.toFixed(1)}ms`, + action: `Reconsider scaling ${targetName} from ${currentPods} to ${newPods} pods; latency increase is significant` + }); + } else if (deltaMs >= LATENCY_THRESHOLDS.moderate) { + recommendations.push({ + type: 'scaling-caution', + priority: 'high', + target: targetName, + reason: `Scaling down may increase latency by ${deltaMs.toFixed(1)}ms`, + action: `Monitor ${targetName} closely after scaling down; consider gradual rollout` + }); + } + } + + // Scaling up with improvement + if (scalingUp && deltaMs !== null && deltaMs < 0) { + const improvement = Math.abs(deltaMs); + + if (improvement >= LATENCY_THRESHOLDS.significant) { + recommendations.push({ + type: 'scaling-benefit', + priority: 'low', + target: targetName, + reason: `Scaling up reduces latency by ${improvement.toFixed(1)}ms`, + action: `Scaling ${targetName} to ${newPods} pods is beneficial; consider permanent capacity increase` + }); + } + } + + // Minimal improvement from scaling up - cost consideration + if (scalingUp && (deltaMs === null || Math.abs(deltaMs) < LATENCY_THRESHOLDS.minor)) { + recommendations.push({ + type: 'cost-efficiency', + priority: 'medium', + target: targetName, + reason: `Scaling from ${currentPods} to ${newPods} shows minimal latency benefit`, + action: `Review if additional pods for ${targetName} are cost-effective; bottleneck may be elsewhere` + }); + } + + // High-impact callers + const callersWithSignificantImpact = affectedCallers.filter( + c => c.deltaMs !== null && Math.abs(c.deltaMs) >= LATENCY_THRESHOLDS.moderate + ); + + if (callersWithSignificantImpact.length > 0) { + const topCaller = callersWithSignificantImpact[0]; + recommendations.push({ + type: 'propagation-awareness', + priority: 'medium', + target: topCaller.name || topCaller.serviceId, + reason: `${callersWithSignificantImpact.length} caller(s) see latency changes >= ${LATENCY_THRESHOLDS.moderate}ms`, + action: `Inform teams owning upstream services (e.g., ${topCaller.name}) about expected latency changes` + }); + } + + // No significant findings + if (recommendations.length === 0 || + (recommendations.length === 1 && recommendations[0].type === 'data-quality')) { + recommendations.push({ + type: 'proceed', + priority: 'low', + target: targetName, + reason: 'No significant negative impact predicted', + action: `Proceed with scaling ${targetName}; monitor for unexpected behavior` + }); + } + + return recommendations; +} + +module.exports = { + generateFailureRecommendations, + generateScalingRecommendations, + // Exported for testing + _test: { + TRAFFIC_THRESHOLDS, + LATENCY_THRESHOLDS + } +}; diff --git a/src/utils/swagger.js b/src/utils/swagger.js new file mode 100644 index 0000000..1202418 --- /dev/null +++ b/src/utils/swagger.js @@ -0,0 +1,91 @@ +/** + * Swagger UI Setup Module + * + * Conditionally mounts Swagger UI at /api-docs when ENABLE_SWAGGER=true. + * + * SAFETY: + * - Disabled by default (must explicitly set ENABLE_SWAGGER=true) + * - Dependencies are devDependencies only + * - Dynamic require prevents crashes if deps missing in production + * - Server continues running even if Swagger setup fails + */ + +const path = require('path'); +const fs = require('fs'); + +/** + * Setup Swagger UI middleware on the Express app. + * Only mounts if ENABLE_SWAGGER=true environment variable is set. + * + * @param {import('express').Application} app - Express application instance + */ +function setupSwagger(app) { + // SAFETY: Only enable when explicitly requested + const enableSwagger = process.env.ENABLE_SWAGGER; + const isEnabled = enableSwagger && ['true', '1', 'yes'].includes(String(enableSwagger).toLowerCase().trim()); + + if (!isEnabled) { + return; + } + + try { + // Dynamic require to avoid crash if deps not installed (production) + let swaggerUi; + let yaml; + + try { + swaggerUi = require('swagger-ui-express'); + } catch (err) { + console.error('[SWAGGER] swagger-ui-express not installed. Install with: npm install --save-dev swagger-ui-express'); + console.error('[SWAGGER] Swagger UI will not be available. Server continues without it.'); + return; + } + + try { + yaml = require('js-yaml'); + } catch (err) { + console.error('[SWAGGER] js-yaml not installed. Install with: npm install --save-dev js-yaml'); + console.error('[SWAGGER] Swagger UI will not be available. Server continues without it.'); + return; + } + + // Load OpenAPI spec (in root directory, not src/) + const specPath = path.join(__dirname, '..', '..', 'openapi.yaml'); + + if (!fs.existsSync(specPath)) { + console.error(`[SWAGGER] OpenAPI spec not found at: ${specPath}`); + console.error('[SWAGGER] Swagger UI will not be available. Server continues without it.'); + return; + } + + const specContent = fs.readFileSync(specPath, 'utf8'); + const swaggerDocument = yaml.load(specContent); + + // Swagger UI options + const swaggerOptions = { + explorer: true, + customSiteTitle: 'Predictive Analysis Engine API', + customCss: '.swagger-ui .topbar { display: none }', + swaggerOptions: { + persistAuthorization: true, + displayRequestDuration: true, + displayOperationId: true, + tryItOutEnabled: true, + deepLinking: true + } + }; + + // Mount Swagger UI at /swagger and /api-docs + // Note: Each path needs its own serve middleware for static assets to work correctly + app.use('/swagger', swaggerUi.serve, swaggerUi.setup(swaggerDocument, swaggerOptions)); + app.use('/api-docs', swaggerUi.serveFiles(swaggerDocument, swaggerOptions), swaggerUi.setup(swaggerDocument, swaggerOptions)); + + console.log('[SWAGGER] Swagger UI enabled at /swagger and /api-docs'); + } catch (err) { + // Catch-all: log error but don't crash server + console.error('[SWAGGER] Failed to setup Swagger UI:', err.message); + console.error('[SWAGGER] Server continues without Swagger UI.'); + } +} + +module.exports = { setupSwagger }; diff --git a/src/utils/trace.js b/src/utils/trace.js new file mode 100644 index 0000000..999a96d --- /dev/null +++ b/src/utils/trace.js @@ -0,0 +1,124 @@ +const { performance } = require('node:perf_hooks'); + +// Preview caps for trace summaries +const TRACE_PREVIEW_MAX_NODES = 10; +const TRACE_PREVIEW_MAX_EDGES = 10; +const TRACE_PREVIEW_MAX_PATHS = 20; + +/** + * Cap array to max size for preview + * @param {Array} arr - Array to cap + * @param {number} max - Maximum size + * @returns {Array} Capped array + */ +function capArray(arr, max) { + if (!Array.isArray(arr)) return []; + return arr.slice(0, max); +} + +/** + * Create a trace instance for pipeline execution tracking + * + * @param {Object} traceOptions - Trace options from parseTraceOptions + * @returns {Object} Trace API (no-op if trace disabled, active if enabled) + */ +function createTrace(traceOptions = {}) { + const enabled = traceOptions.trace === true; + + if (!enabled) { + // No-op API when trace disabled + return { + stage: async (name, fn) => await fn(), + addWarning: () => {}, + setSummary: () => {}, + finalize: () => null + }; + } + + // Active trace: maintain internal state + const stages = []; + const stageMap = new Map(); // stageName -> stageObject for setSummary + + return { + /** + * Execute a function inside a traced stage + * Measures execution time using performance.now() + * + * @param {string} name - Stage name (kebab-case recommended) + * @param {Function} fn - Async or sync function to execute + * @returns {Promise} Result of fn + */ + stage: async (name, fn) => { + const start = performance.now(); + let result; + try { + result = await fn(); + } finally { + const end = performance.now(); + const ms = Math.round((end - start) * 100) / 100; // 2 decimal places + + const stageObj = { + name, + ms + }; + + stages.push(stageObj); + stageMap.set(name, stageObj); + } + return result; + }, + + /** + * Add a warning to a specific stage + * Warnings are collected and included in trace output + * + * @param {string} stageName - Stage to attach warning to + * @param {string} message - Warning message + */ + addWarning: (stageName, message) => { + const stage = stageMap.get(stageName); + if (stage) { + if (!stage.warnings) { + stage.warnings = []; + } + stage.warnings.push(message); + } + }, + + /** + * Set summary metadata for a stage (after execution) + * Summary should be small (counts, metrics, top-N lists) + * + * @param {string} stageName - Stage to attach summary to + * @param {Object} summary - Summary object (size-limited) + */ + setSummary: (stageName, summary) => { + const stage = stageMap.get(stageName); + if (stage) { + stage.summary = summary; + } + }, + + /** + * Finalize trace and return trace object + * Returns null if trace disabled (already handled by no-op API) + * + * @returns {Object|null} Trace object or null + */ + finalize: () => { + return { + options: traceOptions, + stages, + generatedAt: new Date().toISOString() + }; + } + }; +} + +module.exports = { + createTrace, + TRACE_PREVIEW_MAX_NODES, + TRACE_PREVIEW_MAX_EDGES, + TRACE_PREVIEW_MAX_PATHS, + capArray +}; diff --git a/src/utils/traceOptions.js b/src/utils/traceOptions.js new file mode 100644 index 0000000..f557690 --- /dev/null +++ b/src/utils/traceOptions.js @@ -0,0 +1,21 @@ +/** + * Parse trace options from query parameters + * + * @param {Object} query - Express req.query object + * @returns {Object} Normalized trace options + */ +function parseTraceOptions(query = {}) { + // Helper: treat "true", "1", or boolean true as true + const toBool = (val) => { + return val === true || val === 'true' || val === '1'; + }; + + return { + trace: toBool(query.trace), + includeSnapshot: toBool(query.includeSnapshot), + includeRawPaths: toBool(query.includeRawPaths), + includeEdgeDetails: toBool(query.includeEdgeDetails) + }; +} + +module.exports = { parseTraceOptions }; diff --git a/src/validator.js b/src/utils/validator.js similarity index 75% rename from src/validator.js rename to src/utils/validator.js index 226ab97..ae45190 100644 --- a/src/validator.js +++ b/src/utils/validator.js @@ -30,6 +30,35 @@ function parseServiceIdentifier(body) { throw new Error('Must provide either serviceId OR (name AND namespace)'); } +/** + * Normalize pod parameter aliases (newPods, targetPods, pods) + * Accepts aliases and returns canonical 'newPods' value + * + * @param {Object} body - Request body + * @returns {number} - Normalized newPods value + * @throws {Error} If conflicting values provided or missing + */ +function normalizePodParams(body) { + const candidates = [ + { key: 'newPods', value: body.newPods }, + { key: 'targetPods', value: body.targetPods }, + { key: 'pods', value: body.pods } + ].filter(c => c.value !== undefined && c.value !== null); + + if (candidates.length === 0) { + throw new Error('Must provide newPods (or alias: targetPods, pods)'); + } + + // Check for conflicting values + const uniqueValues = [...new Set(candidates.map(c => c.value))]; + if (uniqueValues.length > 1) { + const conflictDesc = candidates.map(c => `${c.key}=${c.value}`).join(', '); + throw new Error(`Conflicting pod values provided: ${conflictDesc}`); + } + + return candidates[0].value; +} + /** * Validate scaling parameters * @@ -122,6 +151,7 @@ function validateScalingModel(model) { module.exports = { parseServiceIdentifier, + normalizePodParams, validateScalingParams, validateLatencyMetric, validateDepth, diff --git a/test-api.js b/test-api.js index a13cc04..37df402 100644 --- a/test-api.js +++ b/test-api.js @@ -15,7 +15,7 @@ async function test() { const result = await simulateFailure({ serviceId: 'default:frontend', maxDepth: 2 }); console.log('✓ Failure simulation completed'); console.log(` Affected services: ${result.affectedCallers.length}`); - console.log(` Top paths: ${result.criticalPathsBroken.length}`); + console.log(` Top paths: ${result.criticalPathsToTarget.length}`); console.log('\n=== Success ==='); process.exit(0); diff --git a/test-trace-demo.js b/test-trace-demo.js new file mode 100644 index 0000000..0dca286 --- /dev/null +++ b/test-trace-demo.js @@ -0,0 +1,89 @@ +/** + * Demo script showing trace output + * Run with: node test-trace-demo.js + */ + +const { createTrace } = require('./src/trace'); + +async function demoTrace() { + console.log('=== Trace Disabled (backward compatible) ==='); + const noTrace = createTrace({ trace: false }); + + await noTrace.stage('test-stage', async () => { + console.log('Executing work...'); + }); + + const result1 = noTrace.finalize(); + console.log('Result:', result1); // Should be null + + console.log('\n=== Trace Enabled ==='); + const withTrace = createTrace({ + trace: true, + includeSnapshot: false + }); + + // Simulate pipeline stages + await withTrace.stage('scenario-parse', async () => { + // Simulate parsing + await new Promise(r => setTimeout(r, 10)); + }); + withTrace.setSummary('scenario-parse', { + serviceIdResolved: 'default:frontend', + maxDepth: 2 + }); + + await withTrace.stage('staleness-check', async () => { + await new Promise(r => setTimeout(r, 5)); + }); + withTrace.setSummary('staleness-check', { + stale: false, + lastUpdatedSecondsAgo: 30, + windowMinutes: 5 + }); + + await withTrace.stage('fetch-neighborhood', async () => { + await new Promise(r => setTimeout(r, 50)); + }); + withTrace.setSummary('fetch-neighborhood', { + depthUsed: 2, + nodesReturned: 12, + edgesReturned: 18 + }); + + await withTrace.stage('build-snapshot', async () => { + await new Promise(r => setTimeout(r, 15)); + }); + withTrace.setSummary('build-snapshot', { + serviceCount: 12, + edgeCount: 18 + }); + + await withTrace.stage('path-analysis', async () => { + await new Promise(r => setTimeout(r, 25)); + }); + withTrace.setSummary('path-analysis', { + pathsFound: 15, + pathsReturned: 10 + }); + + await withTrace.stage('compute-impact', async () => { + await new Promise(r => setTimeout(r, 20)); + }); + withTrace.setSummary('compute-impact', { + affectedCallersCount: 3, + unreachableCount: 0, + totalLostTrafficRps: 150.5 + }); + + await withTrace.stage('recommendations', async () => { + await new Promise(r => setTimeout(r, 8)); + }); + withTrace.setSummary('recommendations', { + recommendationCount: 2 + }); + + const result2 = withTrace.finalize(); + console.log(JSON.stringify(result2, null, 2)); +} + +demoTrace().catch(console.error); diff --git a/test/addSimulation.test.js b/test/addSimulation.test.js new file mode 100644 index 0000000..c6dd604 --- /dev/null +++ b/test/addSimulation.test.js @@ -0,0 +1,194 @@ +const assert = require('node:assert'); +const { test, describe, beforeEach, afterEach } = require('node:test'); + +// Mocks +let mockBehavior = { + getServicesWithPlacement: async () => ({ ok: true, data: { services: [] } }) +}; + +const mockGraphEngineClient = { + getServicesWithPlacement: async (...args) => mockBehavior.getServicesWithPlacement(...args) +}; + +// Mock the require to intercept graphEngineClient +// We need to use a proxy or handle the require cache manually +// Since we are using commonjs and the module requires clients/graphEngineClient +// We can try to seed the require cache or use a DI approach. +// However, simplest way in node:test without rewiring is often to mock the module if possible or separate logic. + +// Let's rely on the fact that addSimulation.js imports specific method. +// We can mock the module in the cache before requiring addSimulation. + +describe('simulateAdd', () => { + let simulateAdd; + + beforeEach(() => { + // Mock the module + require.cache[require.resolve('../src/clients/graphEngineClient')] = { + exports: mockGraphEngineClient + }; + + // Re-require module under test + delete require.cache[require.resolve('../src/simulation/addSimulation')]; + simulateAdd = require('../src/simulation/addSimulation').simulateAdd; + + // Reset default behavior + mockBehavior.getServicesWithPlacement = async () => ({ + ok: true, + data: { + services: [ + { + placement: { + nodes: [ + { + node: 'node-1', + resources: { + cpu: { usagePercent: 50, cores: 4 }, // 2 cores used, 2 available + ram: { usedMB: 4096, totalMB: 8192 } // 4GB used, 4GB available + } + }, + { + node: 'node-2', + resources: { + cpu: { usagePercent: 90, cores: 4 }, // 3.6 cores used, 0.4 available + ram: { usedMB: 7000, totalMB: 8192 } // 1GB avail + } + } + ] + } + } + ] + } + }); + }); + + afterEach(() => { + delete require.cache[require.resolve('../src/clients/graphEngineClient')]; + delete require.cache[require.resolve('../src/simulation/addSimulation')]; + }); + + test('successfully places pod when capacity exists', async () => { + const request = { + serviceName: 'test-service', + cpuRequest: 1, + ramRequest: 1024, + replicas: 1 + }; + + const result = await simulateAdd(request); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.recommendation.distribution.length, 1); + assert.strictEqual(result.recommendation.distribution[0].node, 'node-1'); + assert.strictEqual(result.recommendation.distribution[0].replicas, 1); + }); + + test('fails when no node has enough capacity', async () => { + const request = { + serviceName: 'test-service', + cpuRequest: 10, // Too big + ramRequest: 1024, + replicas: 1 + }; + + const result = await simulateAdd(request); + + assert.strictEqual(result.success, false); + // Updated assertion for new explanation + assert.ok(result.explanation.includes('Failed to find placement') || result.explanation.includes('Capacity limited'), 'Explanation was: ' + result.explanation); + }); + + test('distributes replicas across multiple nodes', async () => { + mockBehavior.getServicesWithPlacement = async () => ({ + ok: true, + data: { + services: [ + { + placement: { + nodes: [ + { + node: 'node-1', + resources: { cpu: { usagePercent: 0, cores: 2 }, ram: { usedMB: 0, totalMB: 4096 } } + }, + { + node: 'node-2', + resources: { cpu: { usagePercent: 0, cores: 2 }, ram: { usedMB: 0, totalMB: 4096 } } + } + ] + } + } + ] + } + }); + + const request = { + serviceName: 'test-service', + cpuRequest: 1, + ramRequest: 1024, + replicas: 3 + }; + + const result = await simulateAdd(request); + + assert.strictEqual(result.success, true); + const totalPlaced = result.recommendations[0].description.match(/Place 3 replicas/); + assert.ok(totalPlaced); + assert.strictEqual(result.totalCapacityPods, 4); + + // Check new fields + assert.ok(result.suitableNodes); + assert.ok(result.riskAnalysis); + }); + + test('calculates risk when dependencies are missing', async () => { + const request = { + serviceName: 'test-service', + cpuRequest: 1, + ramRequest: 128, + dependencies: [{ serviceId: 'unknown:service', relation: 'calls' }] + }; + + const result = await simulateAdd(request); + + assert.strictEqual(result.riskAnalysis.dependencyRisk, 'high'); + assert.ok(result.riskAnalysis.description.includes('Missing dependencies')); + }); + + test('calculates minimal risk when dependencies exist', async () => { + // Setup mock to have the dependency AND valid nodes + mockBehavior.getServicesWithPlacement = async () => ({ + ok: true, + data: { + services: [ + { + serviceId: 'existing:service', + placement: { + nodes: [{ node: 'node-1', resources: { cpu: { usagePercent: 0, cores: 2 }, ram: { usedMB: 0, totalMB: 4096 } } }] + } + } + ] + } + }); + + const request = { + serviceName: 'test-service', + cpuRequest: 1, + ramRequest: 128, + dependencies: [{ serviceId: 'existing:service', relation: 'calls' }] + }; + + const result = await simulateAdd(request); + + assert.strictEqual(result.riskAnalysis.dependencyRisk, 'low'); + }); + + test('handles error from graph client', async () => { + mockBehavior.getServicesWithPlacement = async () => ({ ok: false, error: 'API Error' }); + + const request = { serviceName: 'test', cpuRequest: 1, ramRequest: 1, replicas: 1 }; + + await assert.rejects(async () => { + await simulateAdd(request); + }, /Failed to fetch cluster state/); + }); +}); diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..e80d577 --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,80 @@ +const assert = require('node:assert'); +const { test, describe, beforeEach, afterEach } = require('node:test'); + +// Store original env +const originalEnv = { ...process.env }; + +describe('Config - Graph Engine Only', () => { + beforeEach(() => { + // Clear cached modules + delete require.cache[require.resolve('../src/config')]; + }); + + afterEach(() => { + // Restore original env + Object.keys(process.env).forEach(key => { + if (!(key in originalEnv)) delete process.env[key]; + }); + Object.assign(process.env, originalEnv); + delete require.cache[require.resolve('../src/config')]; + }); + + test('graphApi.baseUrl defaults to service-graph-engine:3000', () => { + delete process.env.SERVICE_GRAPH_ENGINE_URL; + delete process.env.GRAPH_ENGINE_BASE_URL; + + const config = require('../src/config'); + + assert.strictEqual(config.graphApi.baseUrl, 'http://service-graph-engine:3000'); + }); + + test('graphApi.baseUrl uses SERVICE_GRAPH_ENGINE_URL when set', () => { + process.env.SERVICE_GRAPH_ENGINE_URL = 'http://custom-url:8080'; + + const config = require('../src/config'); + + assert.strictEqual(config.graphApi.baseUrl, 'http://custom-url:8080'); + }); +}); + +describe('Provider Factory - Graph Engine Only', () => { + beforeEach(() => { + delete require.cache[require.resolve('../src/config')]; + delete require.cache[require.resolve('../src/providers')]; + delete require.cache[require.resolve('../src/providers/index')]; + }); + + afterEach(() => { + Object.keys(process.env).forEach(key => { + if (!(key in originalEnv)) delete process.env[key]; + }); + Object.assign(process.env, originalEnv); + delete require.cache[require.resolve('../src/config')]; + delete require.cache[require.resolve('../src/providers')]; + delete require.cache[require.resolve('../src/providers/index')]; + }); + + test('getProvider always returns GraphEngineHttpProvider', () => { + process.env.SERVICE_GRAPH_ENGINE_URL = 'http://localhost:3000'; + + const { getProvider, resetProvider } = require('../src/providers'); + resetProvider(); + + const provider = getProvider(); + + assert.strictEqual(provider.constructor.name, 'GraphEngineHttpProvider'); + }); + + test('getProvider returns same instance on multiple calls (singleton)', () => { + process.env.SERVICE_GRAPH_ENGINE_URL = 'http://localhost:3000'; + + const { getProvider, resetProvider } = require('../src/providers'); + resetProvider(); + + const provider1 = getProvider(); + const provider2 = getProvider(); + + assert.strictEqual(provider1, provider2); + }); +}); + diff --git a/test/graphEngineClient.test.js b/test/graphEngineClient.test.js new file mode 100644 index 0000000..0097618 --- /dev/null +++ b/test/graphEngineClient.test.js @@ -0,0 +1,315 @@ +/** + * Tests for GraphEngineClient and /health endpoint with graphApi field + * + * Uses Node.js built-in test runner and a minimal mock HTTP server. + */ + +const assert = require('node:assert'); +const { test, describe, beforeEach, afterEach } = require('node:test'); +const http = require('node:http'); + +// We need to be able to control config for testing +// Store original env and restore after tests +const originalEnv = { ...process.env }; + +/** + * Create a mock HTTP server that responds with given data + */ +function createMockServer(handler) { + return new Promise((resolve) => { + const server = http.createServer(handler); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + resolve({ server, port, url: `http://127.0.0.1:${port}` }); + }); + }); +} + +/** + * Close mock server + */ +function closeMockServer(server) { + return new Promise((resolve) => { + server.close(resolve); + }); +} + +describe('GraphEngineClient._httpGet', () => { + let mockServer; + + afterEach(async () => { + if (mockServer) { + await closeMockServer(mockServer); + mockServer = null; + } + }); + + test('returns ok:true with parsed JSON on 200 response', async () => { + const responseData = { status: 'OK', stale: false, lastUpdatedSecondsAgo: 30 }; + + const mock = await createMockServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(responseData)); + }); + mockServer = mock.server; + + // Import after setting up mock + const { _httpGet } = require('../src/clients/graphEngineClient'); + + const result = await _httpGet(`${mock.url}/graph/health`, 5000); + + assert.strictEqual(result.ok, true); + assert.deepStrictEqual(result.data, responseData); + }); + + test('returns ok:false with status on non-200 response', async () => { + const mock = await createMockServer((req, res) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal Server Error' })); + }); + mockServer = mock.server; + + const { _httpGet } = require('../src/clients/graphEngineClient'); + + const result = await _httpGet(`${mock.url}/graph/health`, 5000); + + assert.strictEqual(result.ok, false); + assert.strictEqual(result.status, 500); + assert.strictEqual(result.error, 'HTTP 500'); + }); + + test('returns ok:false on timeout', async () => { + const mock = await createMockServer((req, res) => { + // Never respond - let it timeout + // Note: we need to keep the connection open + }); + mockServer = mock.server; + + const { _httpGet } = require('../src/clients/graphEngineClient'); + + // Use very short timeout + const result = await _httpGet(`${mock.url}/graph/health`, 50); + + assert.strictEqual(result.ok, false); + assert.strictEqual(result.error, 'Request timeout'); + }); + + test('returns ok:false on connection refused', async () => { + const { _httpGet } = require('../src/clients/graphEngineClient'); + + // Use a port that's not listening + const result = await _httpGet('http://127.0.0.1:59999/graph/health', 1000); + + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes('ECONNREFUSED') || result.error.includes('connect'), + `Expected connection error, got: ${result.error}`); + }); + + test('returns ok:false on invalid JSON response', async () => { + const mock = await createMockServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('not valid json'); + }); + mockServer = mock.server; + + const { _httpGet } = require('../src/clients/graphEngineClient'); + + const result = await _httpGet(`${mock.url}/graph/health`, 5000); + + assert.strictEqual(result.ok, false); + assert.ok(result.error.startsWith('Invalid JSON response:'), + `Expected 'Invalid JSON response:...' but got: ${result.error}`); + }); + + test('returns ok:false on HTML error page (common proxy error)', async () => { + const mock = await createMockServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('502 Bad Gateway'); + }); + mockServer = mock.server; + + const { _httpGet } = require('../src/clients/graphEngineClient'); + + const result = await _httpGet(`${mock.url}/graph/health`, 5000); + + assert.strictEqual(result.ok, false); + assert.ok(result.error.startsWith('Invalid JSON response:'), + `Expected 'Invalid JSON response:...' but got: ${result.error}`); + }); +}); + +describe('GraphEngineClient.checkGraphHealth', () => { + let mockServer; + + beforeEach(() => { + // Clear require cache to reset config + delete require.cache[require.resolve('../src/config/config')]; + delete require.cache[require.resolve('../src/clients/graphEngineClient')]; + }); + + afterEach(async () => { + // Restore original env + process.env = { ...originalEnv }; + + if (mockServer) { + await closeMockServer(mockServer); + mockServer = null; + } + + // Clear require cache + delete require.cache[require.resolve('../src/config/config')]; + delete require.cache[require.resolve('../src/clients/graphEngineClient')]; + }); + + test('returns health data when API responds', async () => { + const responseData = { status: 'OK', stale: false, lastUpdatedSecondsAgo: 45, windowMinutes: 5 }; + + const mock = await createMockServer((req, res) => { + if (req.url === '/graph/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(responseData)); + } else { + res.writeHead(404); + res.end(); + } + }); + mockServer = mock.server; + + process.env.SERVICE_GRAPH_ENGINE_URL = mock.url; + + const { checkGraphHealth } = require('../src/clients/graphEngineClient'); + + const result = await checkGraphHealth(); + + assert.strictEqual(result.ok, true); + assert.deepStrictEqual(result.data, responseData); + }); +}); + +describe('/health endpoint graphApi field', () => { + // These tests verify the expected response shape + // Full integration would require starting the actual server + + beforeEach(() => { + // Clear require cache to reset config + delete require.cache[require.resolve('../src/config/config')]; + }); + + afterEach(() => { + // Restore original env + process.env = { ...originalEnv }; + // Clear require cache + delete require.cache[require.resolve('../src/config/config')]; + }); + + test('config has graphApi section with expected structure', () => { + // Note: This test validates structure, not defaults, because .env may override defaults + delete require.cache[require.resolve('../src/config/config')]; + + const config = require('../src/config/config'); + + assert.strictEqual(typeof config.graphApi, 'object', 'graphApi should be an object'); + assert.strictEqual(typeof config.graphApi.timeoutMs, 'number', 'timeoutMs should be number'); + }); +}); + +describe('URL normalization', () => { + test('normalizeBaseUrl removes trailing slash', () => { + const { _normalizeBaseUrl } = require('../src/clients/graphEngineClient'); + + assert.strictEqual(_normalizeBaseUrl('http://localhost:3000/'), 'http://localhost:3000'); + assert.strictEqual(_normalizeBaseUrl('http://localhost:3000'), 'http://localhost:3000'); + assert.strictEqual(_normalizeBaseUrl('https://api.example.com/'), 'https://api.example.com'); + }); +}); + +describe('getCentralityTop', () => { + let mockServer; + + afterEach(async () => { + if (mockServer) { + await closeMockServer(mockServer); + mockServer = null; + } + // Restore env + Object.keys(process.env).forEach(key => { + if (!(key in originalEnv)) delete process.env[key]; + }); + Object.assign(process.env, originalEnv); + delete require.cache[require.resolve('../src/config/config')]; + delete require.cache[require.resolve('../src/clients/graphEngineClient')]; + }); + + test('returns error for invalid metric', async () => { + const mock = await createMockServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ metric: 'pagerank', top: [] })); + }); + mockServer = mock.server; + + delete require.cache[require.resolve('../src/config/config')]; + delete require.cache[require.resolve('../src/clients/graphEngineClient')]; + process.env.USE_GRAPH_ENGINE_API = 'true'; + process.env.GRAPH_ENGINE_BASE_URL = mock.url; + + const { getCentralityTop } = require('../src/clients/graphEngineClient'); + const result = await getCentralityTop('invalid_metric', 5); + + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes('Invalid metric')); + }); + + test('returns top services on success', async () => { + const responseData = { + metric: 'pagerank', + top: [ + { service: 'frontend', value: 0.35 }, + { service: 'checkoutservice', value: 0.28 } + ] + }; + + const mock = await createMockServer((req, res) => { + assert.ok(req.url.includes('/centrality/top')); + assert.ok(req.url.includes('metric=pagerank')); + assert.ok(req.url.includes('limit=5')); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(responseData)); + }); + mockServer = mock.server; + + delete require.cache[require.resolve('../src/config/config')]; + delete require.cache[require.resolve('../src/clients/graphEngineClient')]; + process.env.USE_GRAPH_ENGINE_API = 'true'; + process.env.GRAPH_ENGINE_BASE_URL = mock.url; + + const { getCentralityTop } = require('../src/clients/graphEngineClient'); + const result = await getCentralityTop('pagerank', 5); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data.metric, 'pagerank'); + assert.strictEqual(result.data.top.length, 2); + assert.strictEqual(result.data.top[0].service, 'frontend'); + }); + + test('accepts betweenness metric', async () => { + const responseData = { metric: 'betweenness', top: [] }; + + const mock = await createMockServer((req, res) => { + assert.ok(req.url.includes('metric=betweenness')); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(responseData)); + }); + mockServer = mock.server; + + delete require.cache[require.resolve('../src/config/config')]; + delete require.cache[require.resolve('../src/clients/graphEngineClient')]; + process.env.USE_GRAPH_ENGINE_API = 'true'; + process.env.GRAPH_ENGINE_BASE_URL = mock.url; + + const { getCentralityTop } = require('../src/clients/graphEngineClient'); + const result = await getCentralityTop('betweenness', 3); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data.metric, 'betweenness'); + }); +}); diff --git a/test/middleware.test.js b/test/middleware.test.js new file mode 100644 index 0000000..06887a5 --- /dev/null +++ b/test/middleware.test.js @@ -0,0 +1,202 @@ +const assert = require('node:assert'); +const { test, describe, mock, beforeEach, afterEach } = require('node:test'); +const { correlationMiddleware, generateCorrelationId } = require('../src/middleware/correlation'); +const { rateLimitMiddleware, getClientKey, _test } = require('../src/middleware/rateLimit'); + +describe('Correlation ID Middleware', () => { + test('generateCorrelationId returns valid UUID format', () => { + const id = generateCorrelationId(); + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + assert.ok(uuidRegex.test(id), `Expected UUID format, got: ${id}`); + }); + + test('generateCorrelationId returns unique values', () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) { + ids.add(generateCorrelationId()); + } + assert.strictEqual(ids.size, 100, 'Expected 100 unique IDs'); + }); + + test('middleware sets X-Correlation-Id header', () => { + const middleware = correlationMiddleware(); + + const req = { + headers: {}, + method: 'GET', + path: '/health', + query: {} + }; + + let headerSet = null; + const res = { + setHeader: (name, value) => { + if (name === 'X-Correlation-Id') headerSet = value; + }, + on: () => {} + }; + + const next = mock.fn(); + + middleware(req, res, next); + + assert.ok(headerSet, 'X-Correlation-Id header should be set'); + assert.strictEqual(req.correlationId, headerSet, 'req.correlationId should match header'); + assert.strictEqual(next.mock.calls.length, 1, 'next() should be called'); + }); + + test('middleware uses existing correlation ID from request header', () => { + const middleware = correlationMiddleware(); + const existingId = 'existing-correlation-id-123'; + + const req = { + headers: { 'x-correlation-id': existingId }, + method: 'POST', + path: '/simulate/failure', + query: {} + }; + + let headerSet = null; + const res = { + setHeader: (name, value) => { + if (name === 'X-Correlation-Id') headerSet = value; + }, + on: () => {} + }; + + const next = mock.fn(); + + middleware(req, res, next); + + assert.strictEqual(headerSet, existingId, 'Should use existing correlation ID'); + assert.strictEqual(req.correlationId, existingId); + }); + + test('middleware attaches correlationId to request object', () => { + const middleware = correlationMiddleware(); + + const req = { + headers: {}, + method: 'GET', + path: '/test', + query: {} + }; + + const res = { + setHeader: () => {}, + on: () => {} + }; + + middleware(req, res, () => {}); + + assert.ok(req.correlationId, 'correlationId should be attached to req'); + assert.strictEqual(typeof req.correlationId, 'string'); + }); +}); + +describe('Rate Limit Middleware', () => { + beforeEach(() => { + _test.clearStore(); + }); + + afterEach(() => { + _test.stopCleanup(); + }); + + test('getClientKey extracts remoteAddress', () => { + const req = { socket: { remoteAddress: '192.168.1.1' } }; + assert.strictEqual(getClientKey(req), '192.168.1.1'); + }); + + test('getClientKey falls back to req.ip', () => { + const req = { socket: {}, ip: '10.0.0.1' }; + assert.strictEqual(getClientKey(req), '10.0.0.1'); + }); + + test('middleware sets rate limit headers', () => { + const middleware = rateLimitMiddleware({ windowMs: 60000, maxRequests: 10 }); + + const req = { + socket: { remoteAddress: '127.0.0.1' }, + path: '/test' + }; + + const headers = {}; + const res = { + setHeader: (name, value) => { headers[name] = value; }, + status: () => res, + json: () => {} + }; + + const next = mock.fn(); + middleware(req, res, next); + + assert.strictEqual(headers['X-RateLimit-Limit'], 10); + assert.strictEqual(headers['X-RateLimit-Remaining'], 9); + assert.ok(headers['X-RateLimit-Reset'] > 0); + assert.strictEqual(next.mock.calls.length, 1); + }); + + test('middleware returns 429 when limit exceeded', () => { + const middleware = rateLimitMiddleware({ windowMs: 60000, maxRequests: 3 }); + + const req = { + socket: { remoteAddress: '127.0.0.2' }, + path: '/test', + correlationId: 'test-123' + }; + + const headers = {}; + let statusCode = null; + let responseBody = null; + + const res = { + setHeader: (name, value) => { headers[name] = value; }, + status: (code) => { statusCode = code; return res; }, + json: (body) => { responseBody = body; } + }; + + const next = mock.fn(); + + // First 3 requests should pass + for (let i = 0; i < 3; i++) { + middleware(req, res, next); + } + assert.strictEqual(next.mock.calls.length, 3); + + // 4th request should be rate limited + middleware(req, res, next); + + assert.strictEqual(statusCode, 429); + assert.strictEqual(responseBody.error, 'Too many requests'); + assert.ok(responseBody.retryAfterMs > 0); + assert.strictEqual(next.mock.calls.length, 3); // Still 3, not called again + }); + + test('different clients have separate limits', () => { + const middleware = rateLimitMiddleware({ windowMs: 60000, maxRequests: 2 }); + + const req1 = { socket: { remoteAddress: '1.1.1.1' }, path: '/test' }; + const req2 = { socket: { remoteAddress: '2.2.2.2' }, path: '/test' }; + + const res = { + setHeader: () => {}, + status: () => res, + json: () => {} + }; + + const next = mock.fn(); + + // Client 1: 2 requests + middleware(req1, res, next); + middleware(req1, res, next); + + // Client 2: 2 requests + middleware(req2, res, next); + middleware(req2, res, next); + + // All 4 should pass + assert.strictEqual(next.mock.calls.length, 4); + }); +}); diff --git a/test/providers.test.js b/test/providers.test.js new file mode 100644 index 0000000..4729418 --- /dev/null +++ b/test/providers.test.js @@ -0,0 +1,149 @@ +/** + * Tests for GraphEngineHttpProvider utilities + * + * Uses Node.js built-in test runner. + */ + +const assert = require('node:assert'); +const { test, describe } = require('node:test'); + +const { + mergeEdgeMetrics, + normalizeServiceName +} = require('../src/providers/GraphEngineHttpProvider'); + +describe('normalizeServiceName', () => { + test('returns plain name unchanged', () => { + assert.strictEqual(normalizeServiceName('checkoutservice'), 'checkoutservice'); + assert.strictEqual(normalizeServiceName('frontend'), 'frontend'); + }); + + test('extracts name from namespace:name format', () => { + assert.strictEqual(normalizeServiceName('default:checkoutservice'), 'checkoutservice'); + assert.strictEqual(normalizeServiceName('prod:frontend'), 'frontend'); + assert.strictEqual(normalizeServiceName('staging:payment-service'), 'payment-service'); + }); + + test('handles edge case with multiple colons', () => { + // Takes last segment after split + assert.strictEqual(normalizeServiceName('ns:sub:service'), 'service'); + }); +}); + +describe('mergeEdgeMetrics', () => { + test('sums rate correctly', () => { + const a = { rate: 10, errorRate: 0.1, p50: 50, p95: 100, p99: 150 }; + const b = { rate: 20, errorRate: 0.05, p50: 40, p95: 120, p99: 180 }; + + const merged = mergeEdgeMetrics(a, b); + + assert.strictEqual(merged.rate, 30, 'rate should be summed'); + }); + + test('computes rate-weighted errorRate', () => { + const a = { rate: 10, errorRate: 0.1, p50: 50, p95: 100, p99: 150 }; + const b = { rate: 20, errorRate: 0.05, p50: 40, p95: 120, p99: 180 }; + + const merged = mergeEdgeMetrics(a, b); + + // Expected: (0.1 * 10 + 0.05 * 20) / 30 = (1 + 1) / 30 = 0.0667 + const expected = (0.1 * 10 + 0.05 * 20) / 30; + assert.ok( + Math.abs(merged.errorRate - expected) < 0.0001, + `errorRate should be weighted avg: expected ${expected}, got ${merged.errorRate}` + ); + }); + + test('takes max for latency percentiles (conservative)', () => { + const a = { rate: 10, errorRate: 0.1, p50: 50, p95: 100, p99: 150 }; + const b = { rate: 20, errorRate: 0.05, p50: 40, p95: 120, p99: 180 }; + + const merged = mergeEdgeMetrics(a, b); + + assert.strictEqual(merged.p50, 50, 'p50 should be max'); + assert.strictEqual(merged.p95, 120, 'p95 should be max'); + assert.strictEqual(merged.p99, 180, 'p99 should be max'); + }); + + test('handles zero total rate (falls back to max errorRate)', () => { + const a = { rate: 0, errorRate: 0.1, p50: 50, p95: 100, p99: 150 }; + const b = { rate: 0, errorRate: 0.2, p50: 40, p95: 120, p99: 180 }; + + const merged = mergeEdgeMetrics(a, b); + + assert.strictEqual(merged.rate, 0, 'rate should be 0'); + assert.strictEqual(merged.errorRate, 0.2, 'errorRate should be max when rate is 0'); + }); + + test('handles null/undefined metrics gracefully', () => { + const a = { rate: null, errorRate: undefined, p50: 50, p95: null, p99: 150 }; + const b = { rate: 20, errorRate: 0.05, p50: undefined, p95: 120, p99: null }; + + const merged = mergeEdgeMetrics(a, b); + + // null/undefined coerced to 0 + assert.strictEqual(merged.rate, 20, 'null rate treated as 0'); + assert.strictEqual(merged.p50, 50, 'p50 should be max (50 vs 0)'); + assert.strictEqual(merged.p95, 120, 'p95 should be max (0 vs 120)'); + assert.strictEqual(merged.p99, 150, 'p99 should be max (150 vs 0)'); + }); +}); + +describe('edge deduplication integration', () => { + test('duplicate edges merge into single edge with correct metrics', () => { + // Simulate duplicate edges from /neighborhood response + const rawEdges = [ + { from: 'A', to: 'B', rate: 10, errorRate: 0.1, p50: 50, p95: 100, p99: 150 }, + { from: 'A', to: 'B', rate: 20, errorRate: 0.05, p50: 40, p95: 120, p99: 180 }, + { from: 'B', to: 'C', rate: 5, errorRate: 0, p50: 30, p95: 60, p99: 90 } + ]; + + // Simulate the deduplication logic from fetchUpstreamNeighborhood + const edgeMap = new Map(); + + for (const e of rawEdges) { + const key = `${e.from}->${e.to}`; + + const candidate = { + source: e.from, + target: e.to, + rate: e.rate ?? 0, + errorRate: e.errorRate ?? 0, + p50: e.p50 ?? 0, + p95: e.p95 ?? 0, + p99: e.p99 ?? 0 + }; + + const existing = edgeMap.get(key); + if (!existing) { + edgeMap.set(key, candidate); + } else { + // Use the real mergeEdgeMetrics function + const merged = mergeEdgeMetrics(existing, candidate); + edgeMap.set(key, { source: e.from, target: e.to, ...merged }); + } + } + + const edges = Array.from(edgeMap.values()); + + // Assertions + assert.strictEqual(edges.length, 2, 'should have 2 unique edges after merge'); + + const abEdge = edges.find(e => e.source === 'A' && e.target === 'B'); + assert.ok(abEdge, 'A->B edge should exist'); + assert.strictEqual(abEdge.rate, 30, 'A->B rate should be summed (10 + 20)'); + assert.strictEqual(abEdge.p95, 120, 'A->B p95 should be max (100 vs 120)'); + assert.strictEqual(abEdge.p99, 180, 'A->B p99 should be max (150 vs 180)'); + + // Verify weighted errorRate: (0.1*10 + 0.05*20) / 30 = 2/30 = 0.0667 + const expectedErrorRate = (0.1 * 10 + 0.05 * 20) / 30; + assert.ok( + Math.abs(abEdge.errorRate - expectedErrorRate) < 0.0001, + `A->B errorRate should be weighted avg: expected ${expectedErrorRate}, got ${abEdge.errorRate}` + ); + + const bcEdge = edges.find(e => e.source === 'B' && e.target === 'C'); + assert.ok(bcEdge, 'B->C edge should exist'); + assert.strictEqual(bcEdge.rate, 5, 'B->C rate should be unchanged'); + }); +}); diff --git a/test/recommendations.test.js b/test/recommendations.test.js new file mode 100644 index 0000000..f671332 --- /dev/null +++ b/test/recommendations.test.js @@ -0,0 +1,228 @@ +const assert = require('node:assert'); +const { test, describe } = require('node:test'); +const { + generateFailureRecommendations, + generateScalingRecommendations, + _test +} = require('../src/recommendations'); + +const { TRAFFIC_THRESHOLDS, LATENCY_THRESHOLDS } = _test; + +describe('Failure Recommendations', () => { + test('adds data-quality warning when confidence is low', () => { + const result = { + confidence: 'low', + target: { name: 'testservice' }, + totalLostTrafficRps: 0, + affectedCallers: [], + unreachableServices: [], + affectedDownstream: [] + }; + + const recs = generateFailureRecommendations(result); + const dataQualityRec = recs.find(r => r.type === 'data-quality'); + + assert.ok(dataQualityRec, 'Should have data-quality recommendation'); + assert.strictEqual(dataQualityRec.priority, 'high'); + assert.ok(dataQualityRec.reason.includes('stale')); + }); + + test('generates circuit-breaker for critical traffic loss', () => { + const result = { + confidence: 'high', + target: { name: 'checkoutservice' }, + totalLostTrafficRps: 150, // > 100 threshold + affectedCallers: [ + { name: 'frontend', lostTrafficRps: 150 } + ], + unreachableServices: [], + affectedDownstream: [] + }; + + const recs = generateFailureRecommendations(result); + const circuitBreakerRec = recs.find(r => r.type === 'circuit-breaker' && r.priority === 'critical'); + + assert.ok(circuitBreakerRec, 'Should have critical circuit-breaker recommendation'); + assert.ok(circuitBreakerRec.reason.includes('150')); + }); + + test('generates redundancy for multiple callers', () => { + const result = { + confidence: 'high', + target: { name: 'productservice' }, + totalLostTrafficRps: 30, + affectedCallers: [ + { name: 'frontend', lostTrafficRps: 10 }, + { name: 'checkout', lostTrafficRps: 10 }, + { name: 'recommendation', lostTrafficRps: 10 } + ], + unreachableServices: [], + affectedDownstream: [] + }; + + const recs = generateFailureRecommendations(result); + const redundancyRec = recs.find(r => r.type === 'redundancy'); + + assert.ok(redundancyRec, 'Should have redundancy recommendation'); + assert.ok(redundancyRec.reason.includes('3')); + }); + + test('generates topology-review for unreachable services', () => { + const result = { + confidence: 'high', + target: { name: 'gateway' }, + totalLostTrafficRps: 20, + affectedCallers: [], + unreachableServices: [ + { name: 'service-a', lostTrafficRps: 10 }, + { name: 'service-b', lostTrafficRps: 15 } + ], + affectedDownstream: [] + }; + + const recs = generateFailureRecommendations(result); + const topologyRec = recs.find(r => r.type === 'topology-review'); + + assert.ok(topologyRec, 'Should have topology-review recommendation'); + assert.ok(topologyRec.reason.includes('2')); + }); + + test('generates monitoring for low-impact scenarios', () => { + const result = { + confidence: 'high', + target: { name: 'emailservice' }, + totalLostTrafficRps: 2, + affectedCallers: [{ name: 'checkout', lostTrafficRps: 2 }], + unreachableServices: [], + affectedDownstream: [] + }; + + const recs = generateFailureRecommendations(result); + const monitoringRec = recs.find(r => r.type === 'monitoring'); + + assert.ok(monitoringRec, 'Should have monitoring recommendation for low impact'); + assert.strictEqual(monitoringRec.priority, 'low'); + }); +}); + +describe('Scaling Recommendations', () => { + test('adds data-quality warning when confidence is low', () => { + const result = { + confidence: 'low', + target: { name: 'testservice' }, + currentPods: 2, + newPods: 4, + latencyEstimate: { deltaMs: -10 }, + affectedCallers: { items: [] } + }; + + const recs = generateScalingRecommendations(result); + const dataQualityRec = recs.find(r => r.type === 'data-quality'); + + assert.ok(dataQualityRec, 'Should have data-quality recommendation'); + }); + + test('generates scaling-caution for significant latency increase when scaling down', () => { + const result = { + confidence: 'high', + target: { name: 'frontend' }, + currentPods: 4, + newPods: 2, + latencyEstimate: { deltaMs: 60 }, // > 50ms threshold + affectedCallers: { items: [] } + }; + + const recs = generateScalingRecommendations(result); + const cautionRec = recs.find(r => r.type === 'scaling-caution'); + + assert.ok(cautionRec, 'Should have scaling-caution recommendation'); + assert.strictEqual(cautionRec.priority, 'critical'); + assert.ok(cautionRec.reason.includes('60')); + }); + + test('generates scaling-benefit for significant improvement', () => { + const result = { + confidence: 'high', + target: { name: 'api-gateway' }, + currentPods: 2, + newPods: 4, + latencyEstimate: { deltaMs: -55 }, // > 50ms improvement + affectedCallers: { items: [] } + }; + + const recs = generateScalingRecommendations(result); + const benefitRec = recs.find(r => r.type === 'scaling-benefit'); + + assert.ok(benefitRec, 'Should have scaling-benefit recommendation'); + assert.ok(benefitRec.reason.includes('55')); + }); + + test('generates cost-efficiency warning for minimal benefit', () => { + const result = { + confidence: 'high', + target: { name: 'worker' }, + currentPods: 2, + newPods: 10, + latencyEstimate: { deltaMs: -2 }, // < 5ms threshold + affectedCallers: { items: [] } + }; + + const recs = generateScalingRecommendations(result); + const costRec = recs.find(r => r.type === 'cost-efficiency'); + + assert.ok(costRec, 'Should have cost-efficiency recommendation'); + assert.ok(costRec.reason.includes('minimal')); + }); + + test('generates propagation-awareness for affected callers', () => { + const result = { + confidence: 'high', + target: { name: 'database-proxy' }, + currentPods: 2, + newPods: 4, + latencyEstimate: { deltaMs: -30 }, + affectedCallers: { + items: [ + { name: 'api', serviceId: 'default:api', deltaMs: -25 } + ] + } + }; + + const recs = generateScalingRecommendations(result); + const propagationRec = recs.find(r => r.type === 'propagation-awareness'); + + assert.ok(propagationRec, 'Should have propagation-awareness recommendation'); + assert.ok(propagationRec.target.includes('api')); + }); + + test('generates proceed for no significant impact', () => { + const result = { + confidence: 'high', + target: { name: 'logging' }, + currentPods: 2, + newPods: 3, + latencyEstimate: { deltaMs: -8 }, // Between 5-20ms + affectedCallers: { items: [] } + }; + + const recs = generateScalingRecommendations(result); + const proceedRec = recs.find(r => r.type === 'proceed'); + + assert.ok(proceedRec, 'Should have proceed recommendation'); + assert.strictEqual(proceedRec.priority, 'low'); + }); +}); + +describe('Thresholds', () => { + test('traffic thresholds are defined correctly', () => { + assert.strictEqual(TRAFFIC_THRESHOLDS.critical, 100); + assert.strictEqual(TRAFFIC_THRESHOLDS.high, 50); + assert.strictEqual(TRAFFIC_THRESHOLDS.medium, 10); + }); + + test('latency thresholds are defined correctly', () => { + assert.strictEqual(LATENCY_THRESHOLDS.significant, 50); + assert.strictEqual(LATENCY_THRESHOLDS.moderate, 20); + assert.strictEqual(LATENCY_THRESHOLDS.minor, 5); + }); +}); diff --git a/test/riskAnalysis.test.js b/test/riskAnalysis.test.js new file mode 100644 index 0000000..b98de5c --- /dev/null +++ b/test/riskAnalysis.test.js @@ -0,0 +1,109 @@ +const assert = require('node:assert'); +const { test, describe } = require('node:test'); +const { _test } = require('../src/riskAnalysis'); + +const { determineRiskLevel, generateExplanation, parseServiceIdentifier, RISK_THRESHOLDS } = _test; + +describe('Risk Analysis - determineRiskLevel', () => { + test('returns high for top 20% with positive score', () => { + // Rank 0 out of 10 = top 0% + assert.strictEqual(determineRiskLevel(0.5, 0, 10), 'high'); + // Rank 1 out of 10 = top 10% + assert.strictEqual(determineRiskLevel(0.3, 1, 10), 'high'); + }); + + test('returns medium for 20-50% with positive score', () => { + // Rank 2 out of 10 = 20% + assert.strictEqual(determineRiskLevel(0.2, 2, 10), 'medium'); + // Rank 4 out of 10 = 40% + assert.strictEqual(determineRiskLevel(0.1, 4, 10), 'medium'); + }); + + test('returns low for bottom 50% or zero score', () => { + // Rank 5 out of 10 = 50% + assert.strictEqual(determineRiskLevel(0.05, 5, 10), 'low'); + // Zero score + assert.strictEqual(determineRiskLevel(0, 0, 10), 'low'); + }); + + test('handles small lists', () => { + // Single item list - rank 0/1 = 0% + assert.strictEqual(determineRiskLevel(0.5, 0, 1), 'high'); + // Two item list - rank 0/2 = 0% + assert.strictEqual(determineRiskLevel(0.5, 0, 2), 'high'); + // Two item list - rank 1/2 = 50% + assert.strictEqual(determineRiskLevel(0.3, 1, 2), 'low'); + }); + + test('handles empty list gracefully', () => { + // Division by max(0,1) = 1 + assert.strictEqual(determineRiskLevel(0.5, 0, 0), 'low'); + }); +}); + +describe('Risk Analysis - generateExplanation', () => { + test('generates high risk explanation for pagerank', () => { + const explanation = generateExplanation('frontend', 'pagerank', 0.35, 'high'); + assert.ok(explanation.includes('frontend')); + assert.ok(explanation.includes('PageRank')); + assert.ok(explanation.includes('0.3500')); + assert.ok(explanation.includes('critical hub')); + }); + + test('generates medium risk explanation for betweenness', () => { + const explanation = generateExplanation('cartservice', 'betweenness', 0.15, 'medium'); + assert.ok(explanation.includes('cartservice')); + assert.ok(explanation.includes('betweenness centrality')); + assert.ok(explanation.includes('moderate')); + }); + + test('generates low risk explanation', () => { + const explanation = generateExplanation('emailservice', 'pagerank', 0.02, 'low'); + assert.ok(explanation.includes('emailservice')); + assert.ok(explanation.includes('low')); + assert.ok(explanation.includes('Lower risk')); + }); +}); + +describe('Risk Analysis - RISK_THRESHOLDS', () => { + test('thresholds are defined', () => { + assert.ok(RISK_THRESHOLDS.high !== undefined); + assert.ok(RISK_THRESHOLDS.medium !== undefined); + assert.ok(RISK_THRESHOLDS.low !== undefined); + }); + + test('thresholds are in descending order', () => { + assert.ok(RISK_THRESHOLDS.high > RISK_THRESHOLDS.medium); + assert.ok(RISK_THRESHOLDS.medium >= RISK_THRESHOLDS.low); + }); +}); + +describe('Risk Analysis - parseServiceIdentifier', () => { + test('parses plain service name with default namespace', () => { + const result = parseServiceIdentifier('frontend'); + assert.strictEqual(result.serviceId, 'default:frontend'); + assert.strictEqual(result.name, 'frontend'); + assert.strictEqual(result.namespace, 'default'); + }); + + test('parses namespace:name format correctly', () => { + const result = parseServiceIdentifier('kube-system:coredns'); + assert.strictEqual(result.serviceId, 'kube-system:coredns'); + assert.strictEqual(result.name, 'coredns'); + assert.strictEqual(result.namespace, 'kube-system'); + }); + + test('handles custom namespace', () => { + const result = parseServiceIdentifier('prod:api-gateway'); + assert.strictEqual(result.serviceId, 'prod:api-gateway'); + assert.strictEqual(result.name, 'api-gateway'); + assert.strictEqual(result.namespace, 'prod'); + }); + + test('handles service name with multiple colons (uses first colon as separator)', () => { + const result = parseServiceIdentifier('ns:service:with:colons'); + assert.strictEqual(result.serviceId, 'ns:service:with:colons'); + assert.strictEqual(result.name, 'service:with:colons'); + assert.strictEqual(result.namespace, 'ns'); + }); +}); diff --git a/test/simulation.test.js b/test/simulation.test.js index 555d0d5..a345d4e 100644 --- a/test/simulation.test.js +++ b/test/simulation.test.js @@ -281,4 +281,331 @@ test('failure simulation - direct caller loses traffic', () => { assert.strictEqual(affectedCallers[1].lostTrafficRps, 5); }); +/** + * Test: Data freshness confidence logic + */ +test('confidence is "low" when dataFreshness.stale is true', () => { + const dataFreshness = { + source: 'graph-engine', + stale: true, + lastUpdatedSecondsAgo: 600, + windowMinutes: 5 + }; + const confidence = dataFreshness?.stale ? 'low' : 'high'; + + assert.strictEqual(confidence, 'low'); +}); + +test('confidence is "high" when dataFreshness.stale is false', () => { + const dataFreshness = { + source: 'graph-engine', + stale: false, + lastUpdatedSecondsAgo: 30, + windowMinutes: 5 + }; + const confidence = dataFreshness?.stale ? 'low' : 'high'; + + assert.strictEqual(confidence, 'high'); +}); + +test('confidence is "high" when dataFreshness is null', () => { + const dataFreshness = null; + const confidence = dataFreshness?.stale ? 'low' : 'high'; + + // null?.stale is undefined, which is falsy, so confidence is 'high' + assert.strictEqual(confidence, 'high'); +}); + +test('confidence is "high" when dataFreshness is undefined', () => { + const dataFreshness = undefined; + const confidence = dataFreshness?.stale ? 'low' : 'high'; + + assert.strictEqual(confidence, 'high'); +}); + +/** + * Test: Phase 3 - Service ID helpers + */ +const { _test: failureHelpers } = require('../src/failureSimulation'); + +test('parseServiceRef - handles namespace:name format', () => { + const result = failureHelpers.parseServiceRef('production:frontend'); + assert.strictEqual(result.namespace, 'production'); + assert.strictEqual(result.name, 'frontend'); +}); + +test('parseServiceRef - handles plain name format', () => { + const result = failureHelpers.parseServiceRef('checkoutservice'); + assert.strictEqual(result.namespace, 'default'); + assert.strictEqual(result.name, 'checkoutservice'); +}); + +test('parseServiceRef - handles null/undefined', () => { + const result = failureHelpers.parseServiceRef(null); + assert.strictEqual(result.namespace, 'default'); + assert.strictEqual(result.name, ''); +}); + +test('toCanonicalServiceId - creates namespace:name format', () => { + const result = failureHelpers.toCanonicalServiceId('default', 'frontend'); + assert.strictEqual(result, 'default:frontend'); +}); + +test('nodeToOutRef - uses node values when present', () => { + const node = { serviceId: 'frontend', name: 'frontend', namespace: 'prod' }; + const result = failureHelpers.nodeToOutRef(node, 'fallback'); + assert.strictEqual(result.serviceId, 'prod:frontend'); + assert.strictEqual(result.name, 'frontend'); + assert.strictEqual(result.namespace, 'prod'); +}); + +test('nodeToOutRef - falls back to parsing key when node is undefined', () => { + const result = failureHelpers.nodeToOutRef(undefined, 'staging:backend'); + assert.strictEqual(result.serviceId, 'staging:backend'); + assert.strictEqual(result.name, 'backend'); + assert.strictEqual(result.namespace, 'staging'); +}); + +/** + * Test: Phase 3 - Reachability analysis + */ +test('pickEntrypoints - finds nodes with no incoming edges', () => { + // Mock snapshot: A -> B -> C (A is entrypoint) + const snapshot = { + nodes: new Map([['A', {}], ['B', {}], ['C', {}]]), + incomingEdges: new Map([ + ['A', []], + ['B', [{ source: 'A', target: 'B' }]], + ['C', [{ source: 'B', target: 'C' }]] + ]) + }; + + const entrypoints = failureHelpers.pickEntrypoints(snapshot, 'C'); + assert.ok(entrypoints.includes('A'), 'A should be an entrypoint'); + assert.ok(!entrypoints.includes('C'), 'C (blocked) should not be an entrypoint'); +}); + +test('computeReachableNodes - traverses graph excluding blocked node', () => { + // Mock snapshot: A -> B -> C, B -> D + // If B is blocked, only A is reachable from A + const snapshot = { + nodes: new Map([['A', {}], ['B', {}], ['C', {}], ['D', {}]]), + outgoingEdges: new Map([ + ['A', [{ source: 'A', target: 'B' }]], + ['B', [{ source: 'B', target: 'C' }, { source: 'B', target: 'D' }]], + ['C', []], + ['D', []] + ]) + }; + + const reachable = failureHelpers.computeReachableNodes(snapshot, ['A'], 'B'); + + assert.ok(reachable.has('A'), 'A should be reachable'); + assert.ok(!reachable.has('B'), 'B (blocked) should not be reachable'); + assert.ok(!reachable.has('C'), 'C should not be reachable (behind blocked B)'); + assert.ok(!reachable.has('D'), 'D should not be reachable (behind blocked B)'); +}); + +test('computeReachableNodes - can reach nodes via alternate paths', () => { + // Mock snapshot: A -> B -> C, A -> C (alternate path) + // If B is blocked, C is still reachable via A -> C + const snapshot = { + nodes: new Map([['A', {}], ['B', {}], ['C', {}]]), + outgoingEdges: new Map([ + ['A', [{ source: 'A', target: 'B' }, { source: 'A', target: 'C' }]], + ['B', [{ source: 'B', target: 'C' }]], + ['C', []] + ]) + }; + + const reachable = failureHelpers.computeReachableNodes(snapshot, ['A'], 'B'); + + assert.ok(reachable.has('A'), 'A should be reachable'); + assert.ok(!reachable.has('B'), 'B (blocked) should not be reachable'); + assert.ok(reachable.has('C'), 'C should be reachable via alternate path A -> C'); +}); + +test('estimateBoundaryLostTraffic - computes cut edge traffic', () => { + // Mock: A (reachable) -> B (unreachable), rate=100 + const snapshot = { + nodes: new Map([['A', {}], ['B', {}], ['TARGET', {}]]), + incomingEdges: new Map([ + ['A', []], + ['B', [{ source: 'A', target: 'B', rate: 100 }]], + ['TARGET', []] + ]) + }; + + const reachableSet = new Set(['A']); + const lostByNode = failureHelpers.estimateBoundaryLostTraffic(snapshot, reachableSet, 'TARGET'); + + assert.deepStrictEqual(lostByNode.get('B'), { + lostFromTargetRps: 0, + lostFromReachableCutsRps: 100, + lostTotalRps: 100 + }, 'B should have 100 RPS lost traffic from reachable cuts'); +}); + +test('estimateBoundaryLostTraffic - splits traffic from blocked node vs reachable cuts', () => { + // Mock: TARGET -> B (rate=50), A -> B (rate=30) + // Now both are counted separately + const snapshot = { + nodes: new Map([['A', {}], ['B', {}], ['TARGET', {}]]), + incomingEdges: new Map([ + ['A', []], + ['B', [ + { source: 'TARGET', target: 'B', rate: 50 }, + { source: 'A', target: 'B', rate: 30 } + ]], + ['TARGET', []] + ]) + }; + + const reachableSet = new Set(['A']); + const lostByNode = failureHelpers.estimateBoundaryLostTraffic(snapshot, reachableSet, 'TARGET'); + + assert.deepStrictEqual(lostByNode.get('B'), { + lostFromTargetRps: 50, + lostFromReachableCutsRps: 30, + lostTotalRps: 80 + }, 'B should have 50 from target + 30 from reachable cuts = 80 total'); +}); + +test('estimateBoundaryLostTraffic - service with only target edge shows non-zero loss', () => { + // Critical test: B only has incoming edge from blocked TARGET + // This was previously returning 0, now should return the target's traffic + const snapshot = { + nodes: new Map([['A', {}], ['B', {}], ['TARGET', {}]]), + incomingEdges: new Map([ + ['A', []], + ['B', [{ source: 'TARGET', target: 'B', rate: 75 }]], + ['TARGET', []] + ]) + }; + + const reachableSet = new Set(['A']); + const lostByNode = failureHelpers.estimateBoundaryLostTraffic(snapshot, reachableSet, 'TARGET'); + + assert.deepStrictEqual(lostByNode.get('B'), { + lostFromTargetRps: 75, + lostFromReachableCutsRps: 0, + lostTotalRps: 75 + }, 'B should have 75 RPS from target (was previously 0)'); +}); + +/** + * Test: Scaling response includes scalingDirection + */ +test('scalingDirection - computed correctly for scale up', () => { + const currentPods = 2; + const newPods = 4; + const direction = newPods > currentPods ? 'up' : newPods < currentPods ? 'down' : 'none'; + assert.strictEqual(direction, 'up'); +}); + +test('scalingDirection - computed correctly for scale down', () => { + const currentPods = 4; + const newPods = 2; + const direction = newPods > currentPods ? 'up' : newPods < currentPods ? 'down' : 'none'; + assert.strictEqual(direction, 'down'); +}); + +test('scalingDirection - computed correctly for no change', () => { + const currentPods = 3; + const newPods = 3; + const direction = newPods > currentPods ? 'up' : newPods < currentPods ? 'down' : 'none'; + assert.strictEqual(direction, 'none'); +}); + +/** + * Test: Scaling explanation generation + */ +test('scaling explanation - includes key information when latency is known', () => { + const targetName = 'cartservice'; + const currentPods = 2; + const newPods = 4; + const scalingDirection = 'up'; + const baselineMs = 120.5; + const projectedMs = 85.2; + const deltaMs = projectedMs - baselineMs; + const callersCount = 3; + const pathsCount = 2; + + const directionWord = scalingDirection === 'up' ? 'up' : scalingDirection === 'down' ? 'down' : 'at same level'; + const improvementWord = deltaMs < 0 ? 'improves' : deltaMs > 0 ? 'degrades' : 'maintains'; + + const explanation = `Scaling ${targetName} ${directionWord} from ${currentPods} to ${newPods} pods ` + + `${improvementWord} latency by ${Math.abs(deltaMs).toFixed(1)}ms ` + + `(baseline: ${baselineMs.toFixed(1)}ms → projected: ${projectedMs.toFixed(1)}ms). ` + + `${callersCount} upstream caller(s) affected across ${pathsCount} path(s).`; + + assert.ok(explanation.includes('cartservice'), 'Should include target name'); + assert.ok(explanation.includes('up'), 'Should include direction'); + assert.ok(explanation.includes('2 to 4'), 'Should include pod counts'); + assert.ok(explanation.includes('improves'), 'Should indicate improvement'); + assert.ok(explanation.includes('35.3ms'), 'Should include delta magnitude'); + assert.ok(explanation.includes('3 upstream caller'), 'Should include callers count'); +}); + +test('scaling explanation - handles unknown latency gracefully', () => { + const targetName = 'frontend'; + const currentPods = 2; + const newPods = 4; + const scalingDirection = 'up'; + const callersCount = 2; + const pathsCount = 1; + + // Simulate when latency is null + const directionWord = scalingDirection === 'up' ? 'up' : scalingDirection === 'down' ? 'down' : 'at same level'; + const explanation = `Scaling ${targetName} ${directionWord} from ${currentPods} to ${newPods} pods. ` + + `Latency impact unknown due to missing edge metrics. ` + + `${callersCount} upstream caller(s) identified across ${pathsCount} path(s).`; + + assert.ok(explanation.includes('frontend'), 'Should include target name'); + assert.ok(explanation.includes('unknown'), 'Should indicate unknown latency'); + assert.ok(explanation.includes('missing edge metrics'), 'Should explain why unknown'); +}); + +/** + * Test: Warnings array for incomplete data + */ +test('warnings array - generated when paths have incomplete data', () => { + const affectedPaths = [ + { path: ['A', 'B'], pathRps: 100, incompleteData: false }, + { path: ['C', 'D'], pathRps: 50, incompleteData: true }, + { path: ['E', 'F'], pathRps: 25, incompleteData: true } + ]; + + const incompletePathsCount = affectedPaths.filter(p => p.incompleteData).length; + const totalPaths = affectedPaths.length; + + let warnings; + if (incompletePathsCount > 0) { + warnings = [ + `${incompletePathsCount} of ${totalPaths} path(s) have incomplete latency data (missing edge metrics). Results may be partial.` + ]; + } + + assert.ok(warnings !== undefined, 'Warnings should be defined when incomplete data exists'); + assert.strictEqual(warnings.length, 1, 'Should have exactly one warning'); + assert.ok(warnings[0].includes('2 of 3'), 'Should specify count of incomplete paths'); + assert.ok(warnings[0].includes('incomplete latency data'), 'Should mention incomplete data'); +}); + +test('warnings array - not generated when all paths complete', () => { + const affectedPaths = [ + { path: ['A', 'B'], pathRps: 100, incompleteData: false }, + { path: ['C', 'D'], pathRps: 50, incompleteData: false } + ]; + + const incompletePathsCount = affectedPaths.filter(p => p.incompleteData).length; + + let warnings; + if (incompletePathsCount > 0) { + warnings = [`${incompletePathsCount} paths have incomplete data`]; + } + + assert.strictEqual(warnings, undefined, 'Warnings should not be defined when all paths are complete'); +}); + console.log('All tests passed!'); diff --git a/test/trace.test.js b/test/trace.test.js new file mode 100644 index 0000000..a4168a1 --- /dev/null +++ b/test/trace.test.js @@ -0,0 +1,249 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { parseTraceOptions } = require('../src/traceOptions'); +const { createTrace } = require('../src/trace'); + +describe('Trace Options Parser', () => { + it('should parse trace=true as boolean true', () => { + const result = parseTraceOptions({ trace: 'true' }); + assert.strictEqual(result.trace, true); + }); + + it('should parse trace=1 as boolean true', () => { + const result = parseTraceOptions({ trace: '1' }); + assert.strictEqual(result.trace, true); + }); + + it('should parse trace=false as boolean false', () => { + const result = parseTraceOptions({ trace: 'false' }); + assert.strictEqual(result.trace, false); + }); + + it('should parse missing trace as boolean false', () => { + const result = parseTraceOptions({}); + assert.strictEqual(result.trace, false); + }); + + it('should parse all trace options correctly', () => { + const result = parseTraceOptions({ + trace: 'true', + includeSnapshot: '1', + includeRawPaths: 'false', + includeEdgeDetails: true + }); + assert.strictEqual(result.trace, true); + assert.strictEqual(result.includeSnapshot, true); + assert.strictEqual(result.includeRawPaths, false); + assert.strictEqual(result.includeEdgeDetails, true); + }); + + it('should default all options to false when query is empty', () => { + const result = parseTraceOptions({}); + assert.strictEqual(result.trace, false); + assert.strictEqual(result.includeSnapshot, false); + assert.strictEqual(result.includeRawPaths, false); + assert.strictEqual(result.includeEdgeDetails, false); + }); +}); + +describe('Trace Helper - No-op Mode', () => { + it('should return no-op API when trace is disabled', async () => { + const trace = createTrace({ trace: false }); + + let executed = false; + const result = await trace.stage('test-stage', async () => { + executed = true; + return 'result'; + }); + + assert.strictEqual(executed, true); + assert.strictEqual(result, 'result'); + + const finalized = trace.finalize(); + assert.strictEqual(finalized, null); + }); + + it('should not throw on addWarning when disabled', () => { + const trace = createTrace({ trace: false }); + assert.doesNotThrow(() => { + trace.addWarning('test-stage', 'warning'); + }); + }); + + it('should not throw on setSummary when disabled', () => { + const trace = createTrace({ trace: false }); + assert.doesNotThrow(() => { + trace.setSummary('test-stage', { count: 10 }); + }); + }); +}); + +describe('Trace Helper - Active Mode', () => { + it('should capture stage timing when trace enabled', async () => { + const trace = createTrace({ trace: true }); + + await trace.stage('test-stage', async () => { + // Simulate some work + await new Promise(resolve => setTimeout(resolve, 10)); + }); + + const result = trace.finalize(); + assert.notStrictEqual(result, null); + assert.strictEqual(result.stages.length, 1); + assert.strictEqual(result.stages[0].name, 'test-stage'); + assert.ok(result.stages[0].ms >= 10); + }); + + it('should include trace options in finalized result', () => { + const traceOptions = { + trace: true, + includeSnapshot: true, + includeRawPaths: false, + includeEdgeDetails: false + }; + const trace = createTrace(traceOptions); + + const result = trace.finalize(); + assert.deepStrictEqual(result.options, traceOptions); + }); + + it('should include generatedAt timestamp', () => { + const trace = createTrace({ trace: true }); + const result = trace.finalize(); + + assert.ok(result.generatedAt); + assert.ok(new Date(result.generatedAt).toISOString()); + }); + + it('should attach summary to stage', async () => { + const trace = createTrace({ trace: true }); + + await trace.stage('fetch-data', async () => { + // Simulate work + }); + + trace.setSummary('fetch-data', { serviceCount: 12, edgeCount: 18 }); + + const result = trace.finalize(); + assert.deepStrictEqual(result.stages[0].summary, { + serviceCount: 12, + edgeCount: 18 + }); + }); + + it('should attach warnings to stage', async () => { + const trace = createTrace({ trace: true }); + + await trace.stage('process', async () => { + // Simulate work + }); + + trace.addWarning('process', 'Data incomplete'); + trace.addWarning('process', 'Edge missing'); + + const result = trace.finalize(); + assert.deepStrictEqual(result.stages[0].warnings, [ + 'Data incomplete', + 'Edge missing' + ]); + }); + + it('should handle multiple stages', async () => { + const trace = createTrace({ trace: true }); + + await trace.stage('stage1', async () => { + await new Promise(resolve => setTimeout(resolve, 5)); + }); + + await trace.stage('stage2', async () => { + await new Promise(resolve => setTimeout(resolve, 5)); + }); + + const result = trace.finalize(); + assert.strictEqual(result.stages.length, 2); + assert.strictEqual(result.stages[0].name, 'stage1'); + assert.strictEqual(result.stages[1].name, 'stage2'); + }); + + it('should return stage function result', async () => { + const trace = createTrace({ trace: true }); + + const result = await trace.stage('compute', async () => { + return { value: 42 }; + }); + + assert.deepStrictEqual(result, { value: 42 }); + }); +}); + +describe('Trace Backward Compatibility', () => { + it('should not affect response when trace is false', () => { + const traceOptions = { trace: false }; + const trace = createTrace(traceOptions); + + const pipelineTrace = trace.finalize(); + + // When trace is false, finalize returns null + // This ensures backward compatibility: no pipelineTrace field added + assert.strictEqual(pipelineTrace, null); + }); +}); + +describe('Pipeline Trace Integration', () => { + it('should support multiple provider-level stages', async () => { + const trace = createTrace({ trace: true }); + + await trace.stage('staleness-check', async () => { + // Simulate health check + }); + + await trace.stage('fetch-neighborhood', async () => { + // Simulate fetch + }); + + await trace.stage('build-snapshot', async () => { + // Simulate build + }); + + const result = trace.finalize(); + assert.strictEqual(result.stages.length, 3); + assert.strictEqual(result.stages[0].name, 'staleness-check'); + assert.strictEqual(result.stages[1].name, 'fetch-neighborhood'); + assert.strictEqual(result.stages[2].name, 'build-snapshot'); + }); + + it('should support scenario-parse stage', async () => { + const trace = createTrace({ trace: true }); + + await trace.stage('scenario-parse', async () => { + return { serviceIdResolved: 'default:frontend', maxDepth: 2 }; + }); + + trace.setSummary('scenario-parse', { + serviceIdResolved: 'default:frontend', + maxDepth: 2 + }); + + const result = trace.finalize(); + assert.strictEqual(result.stages[0].name, 'scenario-parse'); + assert.ok(result.stages[0].summary); + assert.strictEqual(result.stages[0].summary.serviceIdResolved, 'default:frontend'); + assert.strictEqual(result.stages[0].summary.maxDepth, 2); + }); + + it('should support simulation-level stages', async () => { + const trace = createTrace({ trace: true }); + + await trace.stage('path-analysis', async () => {}); + await trace.stage('compute-impact', async () => {}); + await trace.stage('recommendations', async () => {}); + + trace.setSummary('path-analysis', { pathsFound: 10, pathsReturned: 5 }); + trace.setSummary('compute-impact', { affectedCallersCount: 3, totalLostTrafficRps: 150 }); + trace.setSummary('recommendations', { recommendationCount: 2 }); + + const result = trace.finalize(); + assert.strictEqual(result.stages.length, 3); + assert.ok(result.stages.every(s => s.summary)); + }); +}); diff --git a/verify-schema.js b/verify-schema.js deleted file mode 100644 index 5d7ffb0..0000000 --- a/verify-schema.js +++ /dev/null @@ -1,55 +0,0 @@ -// Temporary script to verify Neo4j schema -const neo4j = require('neo4j-driver'); -require('dotenv').config(); - -const uri = process.env.NEO4J_URI || 'neo4j+s://517b3e75.databases.neo4j.io'; -const user = process.env.NEO4J_USER || 'neo4j'; -const password = process.env.NEO4J_PASSWORD || 'Ex-hfrpIOCfghD-dZ04f2ya3-zbUpBdsZSgjwl6a8Rg'; - -const driver = neo4j.driver(uri, neo4j.auth.basic(user, password)); - -async function verify() { - const session = driver.session(); - try { - console.log('Verifying Neo4j connection...'); - - // 1. Check if CALLS_NOW relationships exist - const result = await session.run(` - MATCH (a:Service)-[r:CALLS_NOW]->(b:Service) - RETURN a.serviceId AS source, b.serviceId AS dest, - r.rate AS rate, r.p95 AS p95 - LIMIT 3 - `); - - console.log('\nSample CALLS_NOW relationships:'); - console.log('Direction: (source) -[:CALLS_NOW]-> (dest)'); - console.log('---'); - - if (result.records.length === 0) { - console.log('WARNING: No CALLS_NOW relationships found in database'); - } else { - result.records.forEach(record => { - console.log(`${record.get('source')} -> ${record.get('dest')}`); - console.log(` rate: ${record.get('rate')}, p95: ${record.get('p95')}`); - }); - } - - // 2. Count services - const countResult = await session.run(` - MATCH (s:Service) RETURN count(s) AS total - `); - console.log(`\nTotal services: ${countResult.records[0].get('total')}`); - - console.log('\n✅ Schema verification complete'); - console.log('Confirmed: (caller:Service)-[:CALLS_NOW]->(callee:Service)'); - console.log('ServiceId format: "namespace:name"'); - - } catch (error) { - console.error('❌ Error during verification:', error.message); - } finally { - await session.close(); - await driver.close(); - } -} - -verify();