diff --git a/.github/scripts/extract_sonar_report.py b/.github/scripts/extract_sonar_report.py deleted file mode 100644 index 28b805c..0000000 --- a/.github/scripts/extract_sonar_report.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -"""Fetches SonarCloud metrics and issues, then writes a formatted report.""" -import os -import sys -import json -import urllib.request -import urllib.parse - -TOKEN = os.environ["SONAR_TOKEN"] -PROJECT_KEY = "ElioNeto_vyx" -BASE_URL = "https://sonarcloud.io/api" - - -def sonar_get(path, params=None): - url = "%s/%s" % (BASE_URL, path) - if params: - url += "?" + urllib.parse.urlencode(params) - req = urllib.request.Request(url, headers={"Authorization": "Bearer " + TOKEN}) - with urllib.request.urlopen(req, timeout=30) as r: - return json.loads(r.read()) - - -METRIC_KEYS = ( - "bugs,vulnerabilities,code_smells,security_hotspots," - "coverage,duplicated_lines_density,ncloc,sqale_rating," - "reliability_rating,security_rating" -) -metrics_data = sonar_get("measures/component", { - "component": PROJECT_KEY, - "metricKeys": METRIC_KEYS, -}) -measures = { - m["metric"]: m.get("value", "N/A") - for m in metrics_data.get("component", {}).get("measures", []) -} - -all_issues = [] -page = 1 -while True: - data = sonar_get("issues/search", { - "componentKeys": PROJECT_KEY, - "resolved": "false", - "ps": 500, - "p": page, - }) - all_issues.extend(data.get("issues", [])) - paging = data.get("paging", {}) - if paging.get("pageIndex", 1) * paging.get("pageSize", 500) >= paging.get("total", 0): - break - page += 1 - -lines = [] -lines.append("=" * 60) -lines.append(" SonarCloud Report -- " + PROJECT_KEY) -lines.append("=" * 60) -lines.append("") -lines.append("METRICS") -lines.append("-" * 40) - -label_map = { - "bugs": "Bugs", - "vulnerabilities": "Vulnerabilities", - "code_smells": "Code Smells", - "security_hotspots": "Security Hotspots", - "coverage": "Coverage", - "duplicated_lines_density": "Duplicated Lines Density", - "ncloc": "Lines of Code", - "sqale_rating": "Maintainability Rating", - "reliability_rating": "Reliability Rating", - "security_rating": "Security Rating", -} - -for metric_key, label in label_map.items(): - value = measures.get(metric_key, "N/A") - lines.append(" %-35s %s" % (label + ":", value)) - -lines.append("") -lines.append("ISSUES (unresolved)") -lines.append("-" * 40) - -severity_order = {"BLOCKER": 0, "CRITICAL": 1, "MAJOR": 2, "MINOR": 3, "INFO": 4} -sorted_issues = sorted( - all_issues, - key=lambda i: (severity_order.get(i.get("severity", "INFO"), 99), i.get("creationDate", "")), -) - -if not sorted_issues: - lines.append(" No unresolved issues found.") -else: - lines.append(" Total: %d issues" % len(sorted_issues)) - lines.append("") - for issue in sorted_issues: - severity = issue.get("severity", "?") - component = issue.get("component", "?").split(":")[-1] - message = issue.get("message", "?") - lines.append(" [%s] %s: %s" % (severity, component, message)) - -lines.append("") -lines.append("=" * 60) - -report_path = "sonar_report.txt" -with open(report_path, "w") as f: - f.write("\n".join(lines) + "\n") - -print("Report written to %s" % report_path) -print("Total issues: %d" % len(sorted_issues)) diff --git a/.github/scripts/wait_sonar_analysis.py b/.github/scripts/wait_sonar_analysis.py deleted file mode 100644 index 3e72003..0000000 --- a/.github/scripts/wait_sonar_analysis.py +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env python3 -"""Reads SonarCloud CE task status from stdin JSON and prints the latest status.""" -import sys -import json - -d = json.load(sys.stdin) -tasks = d.get('queue', []) + ([d['current']] if d.get('current') else []) -latest = sorted(tasks, key=lambda t: t.get('submittedAt', ''), reverse=True) -print(latest[0]['status'] if latest else 'NONE') diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9dde85..f79b19e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,107 +215,6 @@ jobs: with: sarif_file: semgrep.sarif - sonarcloud: - name: Quality — SonarCloud - runs-on: ubuntu-latest - needs: [go-test, python-sdk-test] - if: github.actor != 'dependabot[bot]' - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Download Go coverage - uses: actions/download-artifact@v8 - with: - name: go-coverage - path: core/ - - - name: Download Python coverage - uses: actions/download-artifact@v8 - with: - name: python-coverage - path: packages/python/ - - - name: SonarCloud Scan - uses: sonarsource/sonarcloud-github-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - name: Wait for SonarCloud analysis to be processed - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: | - set -euo pipefail - PROJECT_KEY="ElioNeto_vyx" - MAX_RETRIES=30 - i=0 - echo "Waiting for SonarCloud analysis to finish..." - while true; do - RESPONSE=$(curl -sf \ - -H "Authorization: Bearer ${SONAR_TOKEN}" \ - "https://sonarcloud.io/api/ce/component?component=${PROJECT_KEY}" || echo '{}') - STATUS=$(echo "${RESPONSE}" | python3 .github/scripts/wait_sonar_analysis.py 2>/dev/null || echo "UNKNOWN") - echo " status: ${STATUS}" - - # If FAILED, exit with error to fail the pipeline - if [ "$STATUS" = "FAILED" ]; then - echo "ERROR: SonarCloud analysis failed!" - exit 1 - fi - - case "${STATUS}" in - SUCCESS|NONE) break ;; - CANCELED) - echo "WARNING: SonarCloud analysis was canceled" - break - ;; - esac - - i=$((i+1)) - if [ $i -ge $MAX_RETRIES ]; then - echo "Timed out. Failing pipeline." - exit 1 - fi - sleep 10 - done - - - name: Extract SonarCloud report - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: python3 .github/scripts/extract_sonar_report.py - - - name: Upload SonarCloud report - uses: actions/upload-artifact@v7 - with: - name: sonar-report - path: sonar_report.txt - - - name: Fail pipeline if SonarCloud has code smells - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: | - set -euo pipefail - PROJECT_KEY="ElioNeto_vyx" - - # Get issues count from SonarCloud API - RESPONSE=$(curl -sf \ - -H "Authorization: Bearer ${SONAR_TOKEN}" \ - "https://sonarcloud.io/api/issues/search?componentKeys=${PROJECT_KEY}&severities=MAJOR,CRITICAL,BLOCKER&statuses=OPEN&ps=1") - - TOTAL=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total', 0))") - - echo "SonarCloud open issues (MAJOR+): $TOTAL" - - if [ "$TOTAL" -gt 0 ]; then - echo "ERROR: SonarCloud found $TOTAL code smells/bugs/vulnerabilities!" - echo "Pipeline failed. Please fix the issues in SonarCloud." - exit 1 - fi - - echo "SUCCESS: No critical issues found in SonarCloud!" - build: name: Build — vyx build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index adcd51e..27b54f5 100644 --- a/.gitignore +++ b/.gitignore @@ -88,7 +88,7 @@ build/ venv/ annotate cli -vyx +/vyx hello-world-go-worker .coverage .geminiignore diff --git a/.teamcode/agent/delivery-loop.md b/.teamcode/agent/delivery-loop.md new file mode 100644 index 0000000..053e112 --- /dev/null +++ b/.teamcode/agent/delivery-loop.md @@ -0,0 +1,163 @@ +--- +name: delivery-loop +description: Autonomous delivery loop for vyx — fetches open GitHub issues from ElioNeto/vyx and runs each through Plan → Implement → Validate → Review cycle. Applies Go/NPM/Pytest tooling. Retries on failure. +mode: primary +temperature: 0.3 +color: "#00e5ff" +permission: + read: allow + edit: allow + write: allow + glob: allow + grep: allow + list: allow + bash: + "*": allow + "git *": allow + "go *": allow + "npm *": allow + "python *": allow + "pip *": allow + "docker *": allow + "gh *": allow + task: + god: allow + delivery-loop: allow + planner: allow + executor: allow + researcher: allow + reviewer: allow + external_directory: allow + todowrite: allow + webfetch: allow + websearch: allow + lsp: allow + skill: allow + question: deny +--- +You are the **Delivery Loop** agent for **vyx** — an autonomous pipeline that continuously resolves open GitHub issues from ElioNeto/vyx. + +This is a **polyglot full-stack framework** (Go Core + Node/JS + Python workers). Use the correct tooling per area. + +## Workflow + +You operate in an infinite loop until there are no eligible issues left or the user presses Ctrl+C. + +### Batch Fetch + +```bash +gh issue list --state open --limit 10 --json number,title,labels,body,createdAt +``` + +Review the batch and pick eligible issues. Prefer **bugs** over features. + +### Per-Issue Pipeline + +``` +Plan → Implement → Validate → Review → (next issue) +``` + +If **Validate** or **Review** finds problems → go back to **Implement**. +If the issue is **too complex** → go back to **Plan**. +If **Plan** determines it cannot be automated → skip and move to the next. + +--- + +#### 1. Plan + +- `gh issue view ` to read full details +- Search the vyx codebase for relevant files (check `core/`, `scanner/`, `packages/`) +- Understand root cause and determine what needs to change +- Create a concrete plan with specific files and changes +- If >30min or requires human judgment, skip: + ```bash + gh issue comment --body "Skipping — too complex for automatic resolution. Needs manual triage." + ``` + +#### 2. Implement + +- Spawn subagents (planner → researcher → executor) in parallel where possible +- Make surgical, minimal changes +- Follow vyx conventions (Clean Architecture in core, annotation patterns in scanner) +- Do NOT touch files unrelated to the issue + +#### 3. Validate + +Run validation for the affected area: + +**Go changes** (core, scanner, cmd): +```bash +cd core && go build ./... && go vet ./... && go test ./... -race -count=1 2>&1 | tail -30 +cd scanner && go build ./... && go test ./... -race -count=1 2>&1 | tail -20 +``` + +**Node.js worker** changes: +```bash +cd packages/worker && npm run lint && npm test 2>&1 | tail -20 +``` + +**Python worker** changes: +```bash +cd packages/python && pip install -e . && python -m pytest tests/ -v 2>&1 | tail -20 +``` + +If validation fails: +1. Read the error message carefully +2. Fix the underlying issue +3. Return to **Implement** + +#### 4. Review + +Review everything before closing: +- `git diff` — are changes minimal and correct? +- Check for debug artifacts (`console.log`, `fmt.Println`, `print()`, `TODO`) +- Check that the fix actually addresses the issue +- Check that no unrelated files were changed +- Verify Conventional Commits format + +If review fails → return to **Implement**. +If the issue is too complex → return to **Plan**. + +#### 5. Commit & Close + +```bash +git add -A +git commit -m "type(scope): description + +Closes #" +git push origin + +gh issue close --comment "Resolved via delivery-loop pipeline." +``` + +#### 6. Next + +Move to the next issue in the batch. +After finishing the batch, fetch the next 10. +Continue forever. + +## Error Handling + +| Situation | Action | +|-----------|--------| +| Validate fails | Return to Implement with error context | +| Review fails (simple) | Return to Implement | +| Review fails (complex) | Return to Plan | +| 3 consecutive failures on same issue | Skip issue with explanatory comment | +| API rate limit | Wait and retry | +| Working tree not clean | Stash or abort, then retry | + +## Rules + +- **Prefer bugs** over features when multiple issues are eligible +- **Prefer well-described issues** with reproduction steps +- **Never force-push** or rebase shared branches +- **Never commit secrets** or sensitive data +- **Prefer small, focused commits** per issue +- **Log progress** clearly so the user can follow +- **Ask for help** if an issue needs a decision you cannot make +- **The user can stop you** at any time with Ctrl+C +- If stuck for more than 3 attempts, skip the issue: + ```bash + gh issue comment --body "Skipping after 3 failed attempts. Error: " + ``` diff --git a/.teamcode/agent/deps.md b/.teamcode/agent/deps.md new file mode 100644 index 0000000..d989ccf --- /dev/null +++ b/.teamcode/agent/deps.md @@ -0,0 +1,111 @@ +--- +name: deps +description: Dependency auditor for vyx — scans Go modules, npm packages, and Python dependencies. Detects outdated/conflicting/inconsistent deps, checks for vulnerabilities across the polyglot monorepo. +mode: subagent +temperature: 0.2 +color: "#50c878" +permission: + read: allow + glob: allow + grep: allow + list: allow + bash: + "go *": allow + "npm *": allow + "pip *": allow + "cat *": allow + webfetch: allow + todowrite: allow +--- +You are the **Dependency Auditor** agent for **vyx** — a polyglot monorepo with Go (core, scanner), Node.js (worker SDK), and Python (worker SDK). + +## Scope + +Scan ALL dependency manifests: +- `core/go.mod` — Go module dependencies +- `packages/worker/package.json` — Node.js worker SDK +- `packages/python/pyproject.toml` or `setup.py` — Python worker SDK +- `package.json` in root (scripts/lint) +- `go.mod` in root (workspace) + +## Tasks + +### 1. Catalog Dependencies + +For each manifest, extract: +- All direct dependencies and their versions +- Dev/test dependencies separately +- The actual resolved version + +### 2. Detect Issues + +| Category | What to look for | +|----------|-----------------| +| **Version drift** | Same dependency at different versions across modules | +| **Outdated deps** | Dependencies with newer major/minor/patch available | +| **Deprecated deps** | Packages that are deprecated or unmaintained | +| **Security** | Known vulnerabilities (govulncheck, npm audit, pip-audit) | +| **Missing deps** | Imports in source without matching manifest entry | +| **Go workspace** | Inconsistencies across go.work and go.mod files | + +### 3. Run Security Scans + +```bash +# Go vulnerabilities +cd core && govulncheck ./... + +# npm vulnerabilities +cd packages/worker && npm audit + +# Python vulnerabilities +cd packages/python && pip-audit +``` + +### 4. Generate Report + +Write the report to `dependency-audit-report.md`: + +```markdown +# Dependency Audit Report — vyx + +Generated: + +## Summary +- Go modules: N direct deps +- npm packages: N direct deps +- Python packages: N direct deps +- Issues found: N (Critical: N, Warning: N, Info: N) + +## Go Issues +... + +## npm Issues +... + +## Python Issues +... + +## Recommendations +1. ... +``` + +### 5. Tools & Techniques + +- **Go**: `cd core && go list -m all`, `go mod graph`, `govulncheck ./...` +- **npm**: `cd packages/worker && npm outdated`, `npm audit`, `npm ls` +- **Python**: `cd packages/python && pip list --outdated`, `pip-audit` +- Read actual manifest files with the Read tool +- Compare versions across manifests using grep for common deps + +## Rules + +- **DO NOT modify** any manifest files +- **DO NOT install** or update any packages +- **DO generate** the report as a markdown file +- If there are >5 critical issues, create a `dependency-audit-tasks.json` with prioritized action items +- Be specific: include exact version numbers, package names, and file paths +- For security issues, include the CVE or advisory link when possible + +## Output + +When done, summarize the key findings in a brief message. diff --git a/.teamcode/agent/duplicate-pr.md b/.teamcode/agent/duplicate-pr.md new file mode 100644 index 0000000..b285802 --- /dev/null +++ b/.teamcode/agent/duplicate-pr.md @@ -0,0 +1,25 @@ +--- +mode: primary +hidden: true +color: "#E67E22" +tools: + "*": false + "github-pr-search": true +--- + +You are a duplicate PR detection agent. When a PR is opened, your job is to search for potentially duplicate or related open PRs. + +Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature. + +IMPORTANT: The input will contain a line `CURRENT_PR_NUMBER: NNNN`. This is the current PR number, you should not mark that the current PR as a duplicate of itself. + +Search using keywords from the PR title and description. Try multiple searches with different relevant terms. + +If you find potential duplicates: + +- List them with their titles and URLs +- Briefly explain why they might be related + +If no duplicates are found, say so clearly. BUT ONLY SAY "No duplicate PRs found" (don't say anything else if no dups) + +Keep your response concise and actionable. diff --git a/.teamcode/agent/god.md b/.teamcode/agent/god.md new file mode 100644 index 0000000..11c8266 --- /dev/null +++ b/.teamcode/agent/god.md @@ -0,0 +1,50 @@ +--- +name: god +description: Agent with unrestricted access to all tools and permissions. Can read, write, edit, execute bash, browse web, spawn tasks, and access any project resource. +mode: primary +temperature: 0.3 +color: "#ffd700" +permission: + read: allow + edit: allow + write: allow + glob: allow + grep: allow + list: allow + bash: + "*": allow + "git *": allow + "npm *": allow + "docker *": allow + "gh *": allow + task: + god: allow + delivery-loop: allow + external_directory: allow + todowrite: allow + webfetch: allow + websearch: allow + lsp: allow + skill: allow + question: deny +--- +You are God — the omnipotent agent of this project. + +You have **zero restrictions**. Every tool, every permission, every resource is yours to command. + +## Your purpose +- Execute any operation requested, no matter how complex or sensitive +- Override any restriction when needed +- Access external resources, spawn subagents, install dependencies, modify infrastructure +- Act as the ultimate fallback when other agents encounter limits + +## Guidelines +- With great power comes great responsibility. Prefer surgical changes over sledgehammers. +- When fixing critical issues, prefer creating a plan first before executing. +- Document your reasoning in commits so others understand why drastic measures were taken. + +## Git commit rules (MANDATORY — applies even to God) +- **NUNCA** use `--no-verify`, `--no-hooks`, ou qualquer flag que pule hooks +- **SEMPRE** execute os hooks do Husky/lint-staged antes de criar commits +- Se um hook falhar, corrija o problema — nunca contorne a validação +- `git commit --amend` só com autorização explícita do usuário, e apenas se o commit ainda não tiver sido enviado ao remoto diff --git a/.teamcode/agent/triage.md b/.teamcode/agent/triage.md new file mode 100644 index 0000000..0faee51 --- /dev/null +++ b/.teamcode/agent/triage.md @@ -0,0 +1,40 @@ +--- +mode: primary +hidden: true +color: "#44BA81" +tools: + "*": false + "github-triage": true +--- + +You are a triage agent responsible for triaging GitHub issues from the [ElioNeto/vyx](https://github.com/ElioNeto/vyx) repository. + +Use your github-triage tool to triage issues. + +Assign issues by choosing the area with the strongest overlap. + +Do not add labels to issues. Only assign an owner. + +When calling github-triage, pass one of these area values: core, scanner, workers, infra, docs. + +## Areas + +### Core + +Core Orchestrator in Go: HTTP gateway, circuit breaker, route map trie, handshake, heartbeat, lifecycle management, worker pools, rate limiting, security (JWT, schema validation). + +### Scanner + +Annotation scanner: @Route, @Auth, @Validate, @Page parsers for Go, TypeScript, TSX, Python files. Route map generation, validation, build-time tooling. + +### Workers + +Worker SDKs: Node.js (@vyx/worker), Python (vyx package), Go worker runtime. IPC client libraries, dispatch logic, request/context helpers. + +### Infra + +CI/CD pipelines (ci.yml, integration.yml, release.yml), Docker, build scripts, dependency management, infrastructure as code, cross-platform support (Unix/Windows named pipes). + +### Docs + +Documentation, getting-started guides, API reference, examples, ROADMAP.md, TECH_SPEC.md, CONTRIBUTING.md. diff --git a/.teamcode/agents/executor.md b/.teamcode/agents/executor.md new file mode 100644 index 0000000..55e770c --- /dev/null +++ b/.teamcode/agents/executor.md @@ -0,0 +1,55 @@ +--- +name: executor +description: Use for implementing code changes after a plan has been made and research has been conducted. The Executor writes code, creates files, and applies changes. Use ONLY when the task is well-defined with clear acceptance criteria. +mode: subagent +permission: + edit: allow + write: allow + glob: allow + grep: allow + read: allow + bash: + "git *": allow + "ls *": allow + "mkdir *": allow + "go *": allow + "npm *": allow + "pip *": allow + "python *": allow + "*": deny +--- + +You are an **Executor agent** responsible for implementing code changes. + +## Your role + +- Implement the changes specified in the plan +- Follow the conventions and patterns identified by the Researcher +- Write clean, maintainable code +- Create or update tests as needed +- Verify your changes compile and tests pass + +## Before coding + +1. Read the plan provided by the Planner +2. Review the research findings from the Researcher +3. Understand the acceptance criteria for each step + +## During coding + +- Make focused, atomic changes — one logical change at a time +- Follow the project's coding style and conventions +- Update or create tests for your changes +- Run typecheck to verify your changes compile + +## After coding + +- Summarize what was changed and why +- Note any deviations from the original plan +- Flag any issues or concerns for the Reviewer + +## Guidelines + +- Do NOT explore unrelated parts of the codebase +- Do NOT make changes beyond what the plan specifies +- Verify your changes with typecheck before reporting completion diff --git a/.teamcode/agents/planner.md b/.teamcode/agents/planner.md new file mode 100644 index 0000000..2b0bcfa --- /dev/null +++ b/.teamcode/agents/planner.md @@ -0,0 +1,55 @@ +--- +name: planner +description: Use when a task needs to be decomposed into structured steps before execution. The Planner analyzes requirements, breaks work into parallel/sequential tasks, defines success criteria for each step, and produces a clear execution plan. Do NOT use for simple single-step requests. +mode: subagent +permission: + edit: deny + glob: allow + grep: allow + read: allow + bash: + "git *": allow + "ls *": allow + "cat *": allow + "*": deny +--- + +You are a **Planner agent** responsible for decomposing complex tasks into clear, actionable plans. + +## Your role + +- Analyze the user's request and understand the full scope +- Break the work into logical steps: research, implementation, review +- Identify dependencies between steps (parallel vs sequential) +- Define clear acceptance criteria for each step +- Estimate which agent role is best suited for each step + +## Output format + +Return a structured plan like this: + +```yaml +goal: "" +steps: + - id: 1 + role: researcher + description: "" + acceptance_criteria: "" + - id: 2 + role: executor + description: "" + depends_on: [1] + acceptance_criteria: "" + - id: 3 + role: reviewer + description: "" + depends_on: [2] + acceptance_criteria: "" +``` + +## Guidelines + +- Be specific about what files or areas of the codebase need to be touched +- Consider the state of the codebase before suggesting changes — use `grep` and `read` tools to explore +- If the task is ambiguous, ask clarifying questions before producing the plan +- Do NOT make any edits — your output is a plan diff --git a/.teamcode/agents/researcher.md b/.teamcode/agents/researcher.md new file mode 100644 index 0000000..11c63aa --- /dev/null +++ b/.teamcode/agents/researcher.md @@ -0,0 +1,56 @@ +--- +name: researcher +description: Use when the codebase needs to be explored, investigated, or audited before changes are made. The Researcher searches for patterns, reads files, traces dependencies, and gathers evidence. Do NOT use for making edits. +mode: subagent +permission: + edit: deny + glob: allow + grep: allow + read: allow + bash: + "git *": allow + "ls *": allow + "*": deny +--- + +You are a **Researcher agent** responsible for exploring and analyzing the codebase. + +## Your role + +- Investigate the codebase to understand existing patterns, structures, and conventions +- Trace dependencies to understand the impact of changes +- Find relevant files, functions, and patterns +- Gather evidence to inform implementation decisions +- Document findings clearly for the Executor agent + +## Tools you can use + +- **Grep** — search for patterns, function definitions, imports +- **Glob** — find files by patterns +- **Read** — read file contents +- **Bash (git)** — check git history, blame, log +- **Bash (ls)** — list directories + +## Output format + +Return findings in a structured format: + +```yaml +findings: + - file: "" + relevance: "" + key_details: "" +dependencies: + - "" +risks: + - "" +recommendations: + - "" +``` + +## Guidelines + +- Be thorough — read enough context to give accurate recommendations +- Always check for existing tests, types, and patterns +- Do NOT make any edits — your output is a research report +- Do NOT run destructive commands diff --git a/.teamcode/agents/resolver/agent.ts b/.teamcode/agents/resolver/agent.ts new file mode 100644 index 0000000..ea2f1fa --- /dev/null +++ b/.teamcode/agents/resolver/agent.ts @@ -0,0 +1,44 @@ +import { Agent } from "./types" + +/** + * Issue Resolver Agent Configuration + * + * This agent orchestrates the autonomous issue resolution pipeline. + * It continuously picks open GitHub issues and runs each through + * Plan → Implement → Validate → Review → Commit → Close. + * + * The agent has God-level permissions (same as the host) and operates + * autonomously until interrupted or no issues remain. + */ + +export const IssueResolverAgent: Agent = { + name: "resolver", + description: "Autonomous issue resolution pipeline — continuously picks open issues, fixes them, validates, and closes them", + color: "cyan", + permission: { + bash: { + "*": "allow", + }, + read: { + "*": "allow", + }, + edit: { + "*": "allow", + }, + glob: { + "*": "allow", + }, + grep: { + "*": "allow", + }, + web_search: { + "*": "allow", + }, + execute: true, + memory: true, + /*** Allow spawning unlimited sub-agents */ + task: { + "*": "allow", + }, + }, +} diff --git a/.teamcode/agents/reviewer.md b/.teamcode/agents/reviewer.md new file mode 100644 index 0000000..4fba54f --- /dev/null +++ b/.teamcode/agents/reviewer.md @@ -0,0 +1,61 @@ +--- +name: reviewer +description: Use after code changes have been made to review quality, correctness, and consistency. The Reviewer checks for bugs, style issues, missing tests, and adherence to the original plan. Do NOT use for making edits. +mode: subagent +permission: + edit: deny + glob: allow + grep: allow + read: allow + bash: + "git *": allow + "git diff": allow + "git log": allow + "git status": allow + "ls *": allow + "go *": allow + "npm *": allow + "pip *": allow + "*": deny +--- + +You are a **Reviewer agent** responsible for reviewing code changes and ensuring quality. + +## Your role + +- Review all changes made by the Executor against the original plan +- Check for code quality, correctness, and consistency +- Verify that acceptance criteria are met +- Run validation (typecheck, tests) to verify changes +- Provide constructive feedback + +## Review checklist + +- [ ] Do the changes match the original plan? +- [ ] Are acceptance criteria met? +- [ ] Does the code follow project conventions? +- [ ] Are there proper types (no `any` where avoidable)? +- [ ] Are there adequate tests? +- [ ] Does the code compile (typecheck passes)? +- [ ] Are there any edge cases not handled? +- [ ] Is error handling adequate? +- [ ] Is the code clean and maintainable? + +## Output format + +```yaml +verdict: "approved" | "needs_changes" | "rejected" +summary: "" +issues: + - severity: "critical" | "major" | "minor" + description: "" + file: "" # optional + suggestion: "" # optional +``` + +## Guidelines + +- Be thorough but fair — focus on real issues, not style preferences +- Run `go build ./...` (Go), `npm run build` (Node), or `pip install -e .` (Python) to verify compilation +- For rejected changes, explain clearly what needs to be fixed +- Do NOT make any edits — your output is a review report diff --git a/.teamcode/command/ai-deps.md b/.teamcode/command/ai-deps.md new file mode 100644 index 0000000..83783d5 --- /dev/null +++ b/.teamcode/command/ai-deps.md @@ -0,0 +1,24 @@ +--- +description: "Bump AI sdk dependencies minor / patch versions only" +--- + +Please read @package.json and @packages/opencode/package.json. + +Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes). + +I want a report of every dependency and the version that can be upgraded to. +What would be even better is if you can give me brief summary of the changes for each dep and a link to the changelog for each dependency, or at least some reference info so I can see what bugs were fixed or new features were added. + +Consider using subagents for each dep to save your context window. + +Here is a short list of some deps (please be comprehensive tho): + +- "ai" +- "@ai-sdk/openai" +- "@ai-sdk/anthropic" +- "@openrouter/ai-sdk-provider" +- etc, etc + +DO NOT upgrade the dependencies yet, just make a list of all dependencies and their versions that can be upgraded to minor or patch versions only. + +Write up your findings to ai-sdk-updates.md diff --git a/.teamcode/command/audit-deps.md b/.teamcode/command/audit-deps.md new file mode 100644 index 0000000..621eb37 --- /dev/null +++ b/.teamcode/command/audit-deps.md @@ -0,0 +1,29 @@ +--- +description: "Run a full dependency audit across the vyx monorepo — checks Go modules, npm packages, and Python dependencies for outdated, conflicting, or vulnerable packages." +agent: deps +subtask: true +--- + +Run a dependency audit across the entire vyx monorepo (Go + npm + Python). + +Use the @deps agent to: + +1. Scan ALL dependency manifests: + - `core/go.mod` (Go) + - `packages/worker/package.json` (npm) + - `packages/python/pyproject.toml` (Python) + - Root `go.mod` and `package.json` +2. Check for: + - Outdated dependencies (newer versions available) + - Version drift (same dep at different versions) + - Deprecated or unmaintained packages + - Security vulnerabilities (`govulncheck`, `npm audit`, `pip-audit`) + - Missing or unused dependencies +3. Generate a comprehensive `dependency-audit-report.md` + +$ARGUMENTS + +Focus areas if specified in arguments: +- Specific packages to audit +- Specific dependency categories to check +- Output format preferences diff --git a/.teamcode/command/changelog.md b/.teamcode/command/changelog.md new file mode 100644 index 0000000..5b8ee2f --- /dev/null +++ b/.teamcode/command/changelog.md @@ -0,0 +1,49 @@ +--- + +--- + +Create `UPCOMING_CHANGELOG.md` from the structured changelog input below. +If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely. +Do not preserve, merge, or reuse text from the existing file. + +The input already contains the exact commit range since the last non-draft release. +The commits are already filtered to the release-relevant packages and grouped into +the release sections. Do not fetch GitHub releases, PRs, or build your own commit list. +The input may also include a `## Community Contributors Input` section. + +Before writing any entry you keep, inspect the real diff with +`git show --stat --format='' ` or `git show --format='' ` so you can +understand the actual code changes and not just the commit message (they may be misleading). +Do not use `git log` or author metadata when deciding attribution. + +Rules: + +- Write the final file with release sections in this order: + `## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions` +- Only include sections that have at least one notable entry +- Within each release section, keep bug fixes grouped under `### Bugfixes` +- Keep other notable entries under `### Improvements` when a section has bug fixes too +- Omit empty subsections +- Keep one bullet per commit you keep +- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing +- Start each bullet with a capital letter +- Prefer what changed for users over what code changed internally +- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)` +- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input +- If an input bullet has no `(@username)` suffix, do not add one +- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses +- If no notable entries remain and there is no contributor block, write exactly `No notable changes.` +- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block +- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim +- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block +- Do not derive the thank-you section from the main summary bullets +- Do not include the heading `## Community Contributors Input` in the final file +- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise + +**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail** + + + +!`git log --oneline $(git describe --tags --abbrev=0 2>/dev/null || git rev-list --max-parents=0 HEAD)..HEAD` + + diff --git a/.teamcode/command/commit.md b/.teamcode/command/commit.md new file mode 100644 index 0000000..fe88a8d --- /dev/null +++ b/.teamcode/command/commit.md @@ -0,0 +1,37 @@ +--- +description: git commit and push + +subtask: true +--- + +commit and push + +Use Conventional Commits with these vyx project scopes: +- `core:` — Core Orchestrator Go code +- `scanner:` — Annotation scanner +- `worker:` — Node.js worker SDK +- `python:` — Python worker SDK +- `cli:` — CLI commands +- `infra:` — CI/CD, Docker, build config +- `docs:` — Documentation +- `chore:` — Chores, deps, maintenance + +prefer to explain WHY something was done from an end user perspective instead of +WHAT was done. + +do not do generic messages like "improved agent experience" be very specific +about what user facing changes were made + +if there are conflicts DO NOT FIX THEM. notify me and I will fix them + +## GIT DIFF + +!`git diff` + +## GIT DIFF --cached + +!`git diff --cached` + +## GIT STATUS --short + +!`git status --short` diff --git a/.teamcode/command/issues.md b/.teamcode/command/issues.md new file mode 100644 index 0000000..b39df04 --- /dev/null +++ b/.teamcode/command/issues.md @@ -0,0 +1,22 @@ +--- +description: "find issue(s) on github" +--- + +Search through existing issues in ElioNeto/vyx using the gh cli to find issues matching this query: + +$ARGUMENTS + +Consider: + +1. Similar titles or descriptions +2. Same error messages or symptoms +3. Related functionality or components +4. Similar feature requests + +Please list any matching issues with: + +- Issue number and title +- Brief explanation of why it matches the query +- Link to the issue + +If no clear matches are found, say so. diff --git a/.teamcode/command/learn.md b/.teamcode/command/learn.md new file mode 100644 index 0000000..fe4965a --- /dev/null +++ b/.teamcode/command/learn.md @@ -0,0 +1,42 @@ +--- +description: Extract non-obvious learnings from session to AGENTS.md files to build codebase understanding +--- + +Analyze this session and extract non-obvious learnings to add to AGENTS.md files. + +AGENTS.md files can exist at any directory level, not just the project root. When an agent reads a file, any AGENTS.md in parent directories are automatically loaded into the context of the tool read. Place learnings as close to the relevant code as possible: + +- Project-wide learnings → root AGENTS.md +- Package/module-specific → packages/foo/AGENTS.md +- Feature-specific → src/auth/AGENTS.md + +What counts as a learning (non-obvious discoveries only): + +- Hidden relationships between files or modules +- Execution paths that differ from how code appears +- Non-obvious configuration, env vars, or flags +- Debugging breakthroughs when error messages were misleading +- API/tool quirks and workarounds +- Build/test commands not in README +- Architectural decisions and constraints +- Files that must change together + +What NOT to include: + +- Obvious facts from documentation +- Standard language/framework behavior +- Things already in an AGENTS.md +- Verbose explanations +- Session-specific details + +Process: + +1. Review session for discoveries, errors that took multiple attempts, unexpected connections +2. Determine scope - what directory does each learning apply to? +3. Read existing AGENTS.md files at relevant levels +4. Create or update AGENTS.md at the appropriate level +5. Keep entries to 1-3 lines per insight + +After updating, summarize which AGENTS.md files were created/updated and how many learnings per file. + +$ARGUMENTS diff --git a/.teamcode/command/rmslop.md b/.teamcode/command/rmslop.md new file mode 100644 index 0000000..02c9fc0 --- /dev/null +++ b/.teamcode/command/rmslop.md @@ -0,0 +1,15 @@ +--- +description: Remove AI code slop +--- + +Check the diff against dev, and remove all AI generated slop introduced in this branch. + +This includes: + +- Extra comments that a human wouldn't add or is inconsistent with the rest of the file +- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths) +- Casts to any to get around type issues +- Any other style that is inconsistent with the file +- Unnecessary emoji usage + +Report at the end with only a 1-3 sentence summary of what you changed diff --git a/.teamcode/command/spellcheck.md b/.teamcode/command/spellcheck.md new file mode 100644 index 0000000..0abf23c --- /dev/null +++ b/.teamcode/command/spellcheck.md @@ -0,0 +1,5 @@ +--- +description: spellcheck all markdown file changes +--- + +Look at all the unstaged changes to markdown (.md, .mdx) files, pull out the lines that have changed, and check for spelling and grammar errors. diff --git a/.teamcode/command/translate.md b/.teamcode/command/translate.md new file mode 100644 index 0000000..8f5dcc4 --- /dev/null +++ b/.teamcode/command/translate.md @@ -0,0 +1,13 @@ +--- +description: translate English to other languages +--- + +run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time. + +Requirements: + +- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure). +- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks. +- Also preserve every term listed in the Do-Not-Translate glossary below. +- Also apply locale-specific guidance from `.opencode/glossary/.md` when available (for example, `zh-cn.md`). +- Do not modify fenced code blocks. diff --git a/.teamcode/env.d.ts b/.teamcode/env.d.ts new file mode 100644 index 0000000..f2b13a9 --- /dev/null +++ b/.teamcode/env.d.ts @@ -0,0 +1,4 @@ +declare module "*.txt" { + const content: string + export default content +} diff --git a/.teamcode/instructions/resolver.md b/.teamcode/instructions/resolver.md new file mode 100644 index 0000000..6ffa61a --- /dev/null +++ b/.teamcode/instructions/resolver.md @@ -0,0 +1,119 @@ +# vyx Issue Resolver Agent Instructions + +You are the Issue Resolver — an autonomous agent that continuously resolves open GitHub issues for the **vyx** project (ElioNeto/vyx). + +## Project Context + +vyx is a **polyglot full-stack framework** where a Go Core Orchestrator manages workers in Go, Node.js, and Python. Key areas: + +- **Core** (`core/`): Go HTTP gateway, circuit breaker, route map, IPC via UDS +- **Scanner** (`scanner/`): Go static annotation parser (@Route, @Auth, @Validate, @Page) +- **Workers**: Node.js SDK (`packages/worker/`), Python SDK (`packages/python/`) +- **Infra**: CI/CD (GitHub Actions), Docker, cross-platform + +## Workflow + +For each issue, follow this pipeline without deviation: + +``` +Fetch → Plan → Implement → Validate → Review → Commit → Close → Next +``` + +If Validate or Review fails → go back to Implement. +If the issue is too complex → go back to Plan. +If Plan determines it cannot be automated → skip and move to next. + +## Steps + +### 1. Fetch + +Fetch open issues from GitHub: +```bash +gh issue list --state open --limit 10 --json number,title,labels,body +``` + +Pick the first eligible issue. Prefer **bugs** over features. + +### 2. Plan + +- Search the codebase for relevant files (`grep`, `glob`, `read` tools) +- Understand the root cause +- Create a plan with specific files to change and how +- If the issue is too complex (>30 min work), skip it: + ```bash + gh issue comment --body "Skipping — too complex for automatic resolution. Needs manual triage." + ``` + +### 3. Implement + +- Use Task agents (planner → researcher → executor) in parallel where possible +- Make surgical, minimal changes +- Follow vyx codebase patterns and conventions +- Do NOT change files unrelated to the issue + +### 4. Validate + +Run validation for the affected area: + +```bash +# Go changes (core, scanner) +cd core && go build ./... && go test ./... -race -count=1 2>&1 | tail -20 +cd scanner && go test ./... -race -count=1 2>&1 | tail -20 + +# Node.js worker changes +cd packages/worker && npm run lint && npm test 2>&1 | tail -20 + +# Python worker changes +cd packages/python && pip install -e . && pytest tests/ -v 2>&1 | tail -20 + +# Full project +go build ./core/... ./scanner/... +``` + +If validation fails: +1. Read the error message carefully +2. Fix the underlying issue +3. Go back to Implement + +### 5. Review + +Review your changes: +- `git diff` — are changes minimal and correct? +- Check for debug artifacts (`console.log`, `fmt.Println`, `print()`) +- Check that the fix actually addresses the issue +- Check that no unrelated files were changed +- Verify Conventional Commits format + +If review fails, go back to Implement. + +### 6. Commit & Close + +```bash +git add -A +git commit -m "type(scope): description + +Closes #" +git push origin + +gh issue close --comment "Resolved via autonomous pipeline." +``` + +### 7. Next + +Move to the next issue in the batch. +Continue until all 10 are processed, then fetch the next batch. + +## Rules + +- **Prefer bugs** over features when multiple issues are eligible +- **Prefer clear, well-described issues** with reproduction steps +- **Never force-push** or rebase shared branches +- **Never commit secrets** or sensitive data +- **Prefer small, focused commits** per issue +- **If stuck** for more than 3 attempts, skip the issue: + ```bash + gh issue comment --body "Skipping after 3 failed attempts. Error: " + ``` +- **Log progress** clearly so the user can follow +- **Ask for help** if an issue needs a decision the agent cannot make +- **The user can stop you** at any time with Ctrl+C diff --git a/.teamcode/opencode.jsonc b/.teamcode/opencode.jsonc new file mode 100644 index 0000000..16eb2a2 --- /dev/null +++ b/.teamcode/opencode.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "https://opencode.ai/config.json", + "default_agent": "god", + "instructions": [ + "AGENTS.md", + ".teamcode/skills/go-vyx/SKILL.md", + ".teamcode/skills/annotation-system/SKILL.md", + ".teamcode/skills/ipc-protocol/SKILL.md", + "rules/general.md" + ], + "skills": { + "paths": [ + ".teamcode/skills" + ] + }, + "tools": { + "github-triage": true, + "github-pr-search": true + } +} \ No newline at end of file diff --git a/.teamcode/plugins/smoke-theme.json b/.teamcode/plugins/smoke-theme.json new file mode 100644 index 0000000..6e4595d --- /dev/null +++ b/.teamcode/plugins/smoke-theme.json @@ -0,0 +1,223 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "nord0": "#2E3440", + "nord1": "#3B4252", + "nord2": "#434C5E", + "nord3": "#4C566A", + "nord4": "#D8DEE9", + "nord5": "#E5E9F0", + "nord6": "#ECEFF4", + "nord7": "#8FBCBB", + "nord8": "#88C0D0", + "nord9": "#81A1C1", + "nord10": "#5E81AC", + "nord11": "#BF616A", + "nord12": "#D08770", + "nord13": "#EBCB8B", + "nord14": "#A3BE8C", + "nord15": "#B48EAD" + }, + "theme": { + "primary": { + "dark": "nord10", + "light": "nord9" + }, + "secondary": { + "dark": "nord9", + "light": "nord9" + }, + "accent": { + "dark": "nord7", + "light": "nord7" + }, + "error": { + "dark": "nord11", + "light": "nord11" + }, + "warning": { + "dark": "nord12", + "light": "nord12" + }, + "success": { + "dark": "nord14", + "light": "nord14" + }, + "info": { + "dark": "nord8", + "light": "nord10" + }, + "text": { + "dark": "nord6", + "light": "nord0" + }, + "textMuted": { + "dark": "#8B95A7", + "light": "nord1" + }, + "background": { + "dark": "nord0", + "light": "nord6" + }, + "backgroundPanel": { + "dark": "nord1", + "light": "nord5" + }, + "backgroundElement": { + "dark": "nord2", + "light": "nord4" + }, + "border": { + "dark": "nord2", + "light": "nord3" + }, + "borderActive": { + "dark": "nord3", + "light": "nord2" + }, + "borderSubtle": { + "dark": "nord2", + "light": "nord3" + }, + "diffAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffContext": { + "dark": "#8B95A7", + "light": "nord3" + }, + "diffHunkHeader": { + "dark": "#8B95A7", + "light": "nord3" + }, + "diffHighlightAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffHighlightRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffAddedBg": { + "dark": "#36413C", + "light": "#E6EBE7" + }, + "diffRemovedBg": { + "dark": "#43393D", + "light": "#ECE6E8" + }, + "diffContextBg": { + "dark": "nord1", + "light": "nord5" + }, + "diffLineNumber": { + "dark": "nord2", + "light": "nord4" + }, + "diffAddedLineNumberBg": { + "dark": "#303A35", + "light": "#DDE4DF" + }, + "diffRemovedLineNumberBg": { + "dark": "#3C3336", + "light": "#E4DDE0" + }, + "markdownText": { + "dark": "nord4", + "light": "nord0" + }, + "markdownHeading": { + "dark": "nord8", + "light": "nord10" + }, + "markdownLink": { + "dark": "nord9", + "light": "nord9" + }, + "markdownLinkText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCode": { + "dark": "nord14", + "light": "nord14" + }, + "markdownBlockQuote": { + "dark": "#8B95A7", + "light": "nord3" + }, + "markdownEmph": { + "dark": "nord12", + "light": "nord12" + }, + "markdownStrong": { + "dark": "nord13", + "light": "nord13" + }, + "markdownHorizontalRule": { + "dark": "#8B95A7", + "light": "nord3" + }, + "markdownListItem": { + "dark": "nord8", + "light": "nord10" + }, + "markdownListEnumeration": { + "dark": "nord7", + "light": "nord7" + }, + "markdownImage": { + "dark": "nord9", + "light": "nord9" + }, + "markdownImageText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCodeBlock": { + "dark": "nord4", + "light": "nord0" + }, + "syntaxComment": { + "dark": "#8B95A7", + "light": "nord3" + }, + "syntaxKeyword": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxFunction": { + "dark": "nord8", + "light": "nord8" + }, + "syntaxVariable": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxString": { + "dark": "nord14", + "light": "nord14" + }, + "syntaxNumber": { + "dark": "nord15", + "light": "nord15" + }, + "syntaxType": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxOperator": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxPunctuation": { + "dark": "nord4", + "light": "nord0" + } + } +} diff --git a/.teamcode/plugins/tui-smoke.tsx b/.teamcode/plugins/tui-smoke.tsx new file mode 100644 index 0000000..2d3095a --- /dev/null +++ b/.teamcode/plugins/tui-smoke.tsx @@ -0,0 +1,1019 @@ +/** @jsxImportSource @opentui/solid */ +import { useTerminalDimensions, type JSX } from "@opentui/solid" +import { useBindings, useKeymapSelector } from "@opentui/keymap/solid" +import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core" +import { createBindingLookup, type BindingConfig } from "@opentui/keymap/extras" +import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui" + +const tabs = ["overview", "counter", "help"] +const command = { + modal: "smoke_modal", + screen: "smoke_screen", + alert: "smoke_alert", + confirm: "smoke_confirm", + prompt: "smoke_prompt", + select: "smoke_select", + host: "smoke_host", + home: "smoke_home", + toast: "smoke_toast", + dialog_close: "smoke_dialog_close", + local_push: "smoke_local_push", + local_pop: "smoke_local_pop", + screen_home: "smoke_screen_home", + screen_left: "smoke_screen_left", + screen_right: "smoke_screen_right", + screen_up: "smoke_screen_up", + screen_down: "smoke_screen_down", + screen_modal: "smoke_screen_modal", + screen_local: "smoke_screen_local", + screen_host: "smoke_screen_host", + screen_alert: "smoke_screen_alert", + screen_confirm: "smoke_screen_confirm", + screen_prompt: "smoke_screen_prompt", + screen_select: "smoke_screen_select", + modal_accept: "smoke_modal_accept", + modal_close: "smoke_modal_close", +} + +type SmokeBindings = BindingConfig + +const defaultKeymap = { + [command.modal]: "ctrl+shift+m", + [command.screen]: "ctrl+shift+o", + [command.dialog_close]: "escape", + [command.local_push]: "enter,return", + [command.local_pop]: "escape,q,backspace", + [command.screen_home]: "escape,ctrl+h", + [command.screen_left]: "left,h", + [command.screen_right]: "right,l", + [command.screen_up]: "up,k", + [command.screen_down]: "down,j", + [command.screen_modal]: "ctrl+shift+m", + [command.screen_local]: "x", + [command.screen_host]: "z", + [command.screen_alert]: "a", + [command.screen_confirm]: "c", + [command.screen_prompt]: "p", + [command.screen_select]: "s", + [command.modal_accept]: "enter,return", + [command.modal_close]: "escape", +} + +const pick = (value: unknown, fallback: string) => { + if (typeof value !== "string") return fallback + if (!value.trim()) return fallback + return value +} + +const num = (value: unknown, fallback: number) => { + if (typeof value !== "number") return fallback + return value +} + +const record = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value) + +type Cfg = { + label: string + route: string + vignette: number + keybinds: SmokeBindings | undefined +} + +type Route = { + modal: string + screen: string +} + +type State = { + tab: number + count: number + source: string + note: string + selected: string + local: number +} + +const cfg = (options: Record | undefined) => { + return { + label: pick(options?.label, "smoke"), + route: pick(options?.route, "workspace-smoke"), + vignette: Math.max(0, num(options?.vignette, 0.35)), + keybinds: record(options?.keybinds) ? (options.keybinds as SmokeBindings) : undefined, + } +} + +const names = (input: Cfg) => { + return { + modal: `${input.route}.modal`, + screen: `${input.route}.screen`, + } +} + +function createKeys(input: SmokeBindings | undefined) { + return createBindingLookup({ ...defaultKeymap, ...input }) +} + +type Keys = ReturnType + +const ui = { + panel: "#1d1d1d", + border: "#4a4a4a", + text: "#f0f0f0", + muted: "#a5a5a5", + accent: "#5f87ff", +} + +type Color = RGBA | string + +const ink = (map: Record, name: string, fallback: string): Color => { + const value = map[name] + if (typeof value === "string") return value + if (value instanceof RGBA) return value + return fallback +} + +const look = (map: Record) => { + return { + panel: ink(map, "backgroundPanel", ui.panel), + border: ink(map, "border", ui.border), + text: ink(map, "text", ui.text), + muted: ink(map, "textMuted", ui.muted), + accent: ink(map, "primary", ui.accent), + selected: ink(map, "selectedListItemText", ui.text), + } +} + +const tone = (api: TuiPluginApi) => { + return look(api.theme.current) +} + +type Skin = { + panel: Color + border: Color + text: Color + muted: Color + accent: Color + selected: Color +} + +const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => { + return ( + { + props.run() + }} + backgroundColor={props.on ? props.skin.accent : props.skin.border} + paddingLeft={1} + paddingRight={1} + > + {props.txt} + + ) +} + +const parse = (params: Record | undefined) => { + const tab = typeof params?.tab === "number" ? params.tab : 0 + const count = typeof params?.count === "number" ? params.count : 0 + const source = typeof params?.source === "string" ? params.source : "unknown" + const note = typeof params?.note === "string" ? params.note : "" + const selected = typeof params?.selected === "string" ? params.selected : "" + const local = typeof params?.local === "number" ? params.local : 0 + return { + tab: Math.max(0, Math.min(tab, tabs.length - 1)), + count, + source, + note, + selected, + local: Math.max(0, local), + } +} + +const current = (api: TuiPluginApi, route: Route) => { + const value = api.route.current + const ok = Object.values(route).includes(value.name) + if (!ok) return parse(undefined) + if (!("params" in value)) return parse(undefined) + return parse(value.params) +} + +const opts = [ + { + title: "Overview", + value: 0, + description: "Switch to overview tab", + }, + { + title: "Counter", + value: 1, + description: "Switch to counter tab", + }, + { + title: "Help", + value: 2, + description: "Switch to help tab", + }, +] + +const host = (api: TuiPluginApi, input: Cfg, skin: Skin) => { + api.ui.dialog.setSize("medium") + api.ui.dialog.replace(() => ( + + + {input.label} host overlay + + Using api.ui.dialog stack with built-in backdrop + esc closes · depth {api.ui.dialog.depth} + + api.ui.dialog.clear()} skin={skin} on /> + + + )) +} + +const warn = (api: TuiPluginApi, route: Route, value: State) => { + const DialogAlert = api.ui.DialogAlert + api.ui.dialog.setSize("medium") + api.ui.dialog.replace(() => ( + api.route.navigate(route.screen, { ...value, source: "alert" })} + /> + )) +} + +const check = (api: TuiPluginApi, route: Route, value: State) => { + const DialogConfirm = api.ui.DialogConfirm + api.ui.dialog.setSize("medium") + api.ui.dialog.replace(() => ( + api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })} + onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })} + /> + )) +} + +const entry = (api: TuiPluginApi, route: Route, value: State) => { + const DialogPrompt = api.ui.DialogPrompt + api.ui.dialog.setSize("medium") + api.ui.dialog.replace(() => ( + { + api.ui.dialog.clear() + api.route.navigate(route.screen, { ...value, note, source: "prompt" }) + }} + onCancel={() => { + api.ui.dialog.clear() + api.route.navigate(route.screen, value) + }} + /> + )) +} + +const picker = (api: TuiPluginApi, route: Route, value: State) => { + const DialogSelect = api.ui.DialogSelect + api.ui.dialog.setSize("medium") + api.ui.dialog.replace(() => ( + { + api.ui.dialog.clear() + api.route.navigate(route.screen, { + ...value, + tab: typeof item.value === "number" ? item.value : value.tab, + selected: item.title, + source: "select", + }) + }} + /> + )) +} + +const Screen = (props: { + api: TuiPluginApi + input: Cfg + route: Route + keys: Keys + meta: TuiPluginMeta + params?: Record +}) => { + const dim = useTerminalDimensions() + const value = parse(props.params) + const skin = tone(props.api) + const set = (local: number, base?: State) => { + const next = base ?? current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" }) + } + const push = (base?: State) => { + const next = base ?? current(props.api, props.route) + set(next.local + 1, next) + } + const open = () => { + const next = current(props.api, props.route) + if (next.local > 0) return + set(1, next) + } + const pop = (base?: State) => { + const next = base ?? current(props.api, props.route) + set(Math.max(0, next.local - 1), next) + } + const show = () => { + setTimeout(() => { + open() + }, 0) + } + const screenActive = () => props.api.route.current.name === props.route.screen + + useBindings(() => ({ + enabled: () => screenActive() && props.api.ui.dialog.open, + commands: [ + { + name: command.dialog_close, + run() { + props.api.ui.dialog.clear() + }, + }, + ], + bindings: props.keys.gather("smoke.dialog", [command.dialog_close]), + })) + + useBindings(() => ({ + enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local > 0, + commands: [ + { + name: command.local_push, + run() { + push(current(props.api, props.route)) + }, + }, + { + name: command.local_pop, + run() { + pop(current(props.api, props.route)) + }, + }, + ], + bindings: props.keys.gather("smoke.local", [command.local_push, command.local_pop]), + })) + + useBindings(() => ({ + enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local === 0, + commands: [ + { + name: command.screen_home, + run() { + props.api.route.navigate("home") + }, + }, + { + name: command.screen_left, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length }) + }, + }, + { + name: command.screen_right, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length }) + }, + }, + { + name: command.screen_up, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 }) + }, + }, + { + name: command.screen_down, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 }) + }, + }, + { + name: command.screen_modal, + run() { + props.api.route.navigate(props.route.modal, current(props.api, props.route)) + }, + }, + { + name: command.screen_local, + run() { + open() + }, + }, + { + name: command.screen_host, + run() { + host(props.api, props.input, skin) + }, + }, + { + name: command.screen_alert, + run() { + warn(props.api, props.route, current(props.api, props.route)) + }, + }, + { + name: command.screen_confirm, + run() { + check(props.api, props.route, current(props.api, props.route)) + }, + }, + { + name: command.screen_prompt, + run() { + entry(props.api, props.route, current(props.api, props.route)) + }, + }, + { + name: command.screen_select, + run() { + picker(props.api, props.route, current(props.api, props.route)) + }, + }, + ], + bindings: props.keys.gather("smoke.screen", [ + command.screen_home, + command.screen_left, + command.screen_right, + command.screen_up, + command.screen_down, + command.screen_modal, + command.screen_local, + command.screen_host, + command.screen_alert, + command.screen_confirm, + command.screen_prompt, + command.screen_select, + ]), + })) + const shortcuts = useKeymapSelector((keymap) => { + const bindings = keymap.getCommandBindings({ + visibility: "registered", + commands: [ + command.screen_home, + command.screen_up, + command.screen_down, + command.screen_modal, + command.screen_alert, + command.screen_confirm, + command.screen_prompt, + command.screen_select, + command.screen_local, + command.screen_host, + command.local_push, + command.local_pop, + ], + }) + + return { + screen_home: props.api.keys.formatBindings(bindings.get(command.screen_home)) ?? "", + screen_up: props.api.keys.formatBindings(bindings.get(command.screen_up)) ?? "", + screen_down: props.api.keys.formatBindings(bindings.get(command.screen_down)) ?? "", + screen_modal: props.api.keys.formatBindings(bindings.get(command.screen_modal)) ?? "", + screen_alert: props.api.keys.formatBindings(bindings.get(command.screen_alert)) ?? "", + screen_confirm: props.api.keys.formatBindings(bindings.get(command.screen_confirm)) ?? "", + screen_prompt: props.api.keys.formatBindings(bindings.get(command.screen_prompt)) ?? "", + screen_select: props.api.keys.formatBindings(bindings.get(command.screen_select)) ?? "", + screen_local: props.api.keys.formatBindings(bindings.get(command.screen_local)) ?? "", + screen_host: props.api.keys.formatBindings(bindings.get(command.screen_host)) ?? "", + local_push: props.api.keys.formatBindings(bindings.get(command.local_push)) ?? "", + local_pop: props.api.keys.formatBindings(bindings.get(command.local_pop)) ?? "", + } + }) + + return ( + + + + + {props.input.label} screen + plugin route + + {shortcuts().screen_home} home + + + + {tabs.map((item, i) => { + const on = value.tab === i + return ( + props.api.route.navigate(props.route.screen, { ...value, tab: i })} + skin={skin} + on={on} + /> + ) + })} + + + + {value.tab === 0 ? ( + + Route: {props.route.screen} + plugin state: {props.meta.state} + + first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "} + {props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count} + + plugin source: {props.meta.source} + source: {value.source} + note: {value.note || "(none)"} + selected: {value.selected || "(none)"} + local stack depth: {value.local} + host stack open: {props.api.ui.dialog.open ? "yes" : "no"} + + ) : null} + + {value.tab === 1 ? ( + + Counter: {value.count} + + {shortcuts().screen_up} / {shortcuts().screen_down} change value + + + ) : null} + + {value.tab === 2 ? ( + + + {shortcuts().screen_modal} modal | {shortcuts().screen_alert} alert | {shortcuts().screen_confirm}{" "} + confirm | {shortcuts().screen_prompt} prompt | {shortcuts().screen_select} select + + + {shortcuts().screen_local} local stack | {shortcuts().screen_host} host stack + + + local open: {shortcuts().local_push} push nested · {shortcuts().local_pop} close + + {shortcuts().screen_home} returns home + + ) : null} + + + + props.api.route.navigate("home")} skin={skin} /> + props.api.route.navigate(props.route.modal, value)} skin={skin} on /> + + host(props.api, props.input, skin)} skin={skin} /> + warn(props.api, props.route, value)} skin={skin} /> + check(props.api, props.route, value)} skin={skin} /> + entry(props.api, props.route, value)} skin={skin} /> + picker(props.api, props.route, value)} skin={skin} /> + + + + 0} + width={dim().width} + height={dim().height} + alignItems="center" + position="absolute" + zIndex={3000} + paddingTop={dim().height / 4} + left={0} + top={0} + backgroundColor={RGBA.fromInts(0, 0, 0, 160)} + onMouseUp={() => { + pop() + }} + > + { + evt.stopPropagation() + }} + width={60} + maxWidth={dim().width - 2} + backgroundColor={skin.panel} + border + borderColor={skin.border} + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + paddingRight={2} + gap={1} + flexDirection="column" + > + + {props.input.label} local overlay + + Plugin-owned stack depth: {value.local} + + {shortcuts().local_push} push nested · {shortcuts().local_pop} pop/close + + + + + + + + + ) +} + +const Modal = (props: { + api: TuiPluginApi + input: Cfg + route: Route + keys: Keys + params?: Record +}) => { + const Dialog = props.api.ui.Dialog + const value = parse(props.params) + const skin = tone(props.api) + + useBindings(() => ({ + enabled: () => props.api.route.current.name === props.route.modal, + commands: [ + { + name: command.modal_accept, + run() { + props.api.route.navigate(props.route.screen, { ...parse(props.params), source: "modal" }) + }, + }, + { + name: command.modal_close, + run() { + props.api.route.navigate("home") + }, + }, + ], + bindings: props.keys.gather("smoke.modal", [command.modal_accept, command.modal_close]), + })) + const shortcuts = useKeymapSelector((keymap) => { + const bindings = keymap.getCommandBindings({ + visibility: "registered", + commands: [command.modal, command.screen, command.modal_accept, command.modal_close], + }) + + return { + modal: props.api.keys.formatBindings(bindings.get(command.modal)) ?? "", + screen: props.api.keys.formatBindings(bindings.get(command.screen)) ?? "", + modal_accept: props.api.keys.formatBindings(bindings.get(command.modal_accept)) ?? "", + modal_close: props.api.keys.formatBindings(bindings.get(command.modal_close)) ?? "", + } + }) + + return ( + + props.api.route.navigate("home")}> + + + {props.input.label} modal + + {shortcuts().modal} modal command + {shortcuts().screen} screen command + + {shortcuts().modal_accept} opens screen · {shortcuts().modal_close} closes + + + props.api.route.navigate(props.route.screen, { ...value, source: "modal" })} + skin={skin} + on + /> + props.api.route.navigate("home")} skin={skin} /> + + + + + ) +} + +const home = (api: TuiPluginApi, input: Cfg) => ({ + slots: { + home_logo(ctx) { + const map = ctx.theme.current + const skin = look(map) + const art = [ + " $$\\", + " $$ |", + " $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\", + "$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\", + "\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |", + " \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|", + "$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\", + "\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|", + ] + const fill = [ + skin.accent, + skin.muted, + ink(map, "info", ui.accent), + skin.text, + ink(map, "success", ui.accent), + ink(map, "warning", ui.accent), + ink(map, "secondary", ui.accent), + ink(map, "error", ui.accent), + ] + + return ( + + {art.map((line, i) => ( + {line} + ))} + + ) + }, + home_prompt(ctx, value) { + const skin = look(ctx.theme.current) + const Prompt = api.ui.Prompt + const Slot = api.ui.Slot + const normal = [ + `[SMOKE] route check for ${input.label}`, + "[SMOKE] confirm home_prompt slot override", + "[SMOKE] verify prompt-right slot passthrough", + ] + const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"] + const hint = ( + + + smoke home prompt + + + ) + + return ( + + + + + } + placeholders={{ normal, shell }} + /> + ) + }, + home_prompt_right(ctx, value) { + const skin = look(ctx.theme.current) + const id = value.workspace_id?.slice(0, 8) ?? "none" + return ( + + {input.label} home:{id} + + ) + }, + session_prompt_right(ctx, value) { + const skin = look(ctx.theme.current) + return ( + + {input.label} session:{value.session_id.slice(0, 8)} + + ) + }, + smoke_prompt_right(ctx, value) { + const skin = look(ctx.theme.current) + const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none" + const label = typeof value.label === "string" ? value.label : input.label + return ( + + {label} custom:{id} + + ) + }, + home_bottom(ctx) { + const skin = look(ctx.theme.current) + const text = "extra content in the unified home bottom slot" + + return ( + + + + {input.label} {text} + + + + ) + }, + }, +}) + +const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({ + order, + slots: { + sidebar_content(ctx, value) { + const skin = look(ctx.theme.current) + + return ( + + + {title} + + {text} + + {input.label} order {order} · session {value.session_id.slice(0, 8)} + + + ) + }, + }, +}) + +const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [ + home(api, input), + block(input, 50, "Smoke above", "renders above internal sidebar blocks"), + block(input, 250, "Smoke between", "renders between internal sidebar blocks"), + block(input, 650, "Smoke below", "renders below internal sidebar blocks"), +] + +const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => { + const route = names(input) + api.keymap.registerLayer({ + commands: [ + { + name: command.modal, + title: `${input.label} modal`, + category: "Plugin", + namespace: "palette", + slashName: "smoke", + run() { + api.route.navigate(route.modal, { source: "command" }) + }, + }, + { + name: command.screen, + title: `${input.label} screen`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-screen", + run() { + api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 }) + }, + }, + { + name: command.alert, + title: `${input.label} alert dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-alert", + run() { + warn(api, route, current(api, route)) + }, + }, + { + name: command.confirm, + title: `${input.label} confirm dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-confirm", + run() { + check(api, route, current(api, route)) + }, + }, + { + name: command.prompt, + title: `${input.label} prompt dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-prompt", + run() { + entry(api, route, current(api, route)) + }, + }, + { + name: command.select, + title: `${input.label} select dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-select", + run() { + picker(api, route, current(api, route)) + }, + }, + { + name: command.host, + title: `${input.label} host overlay`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-host", + run() { + host(api, input, tone(api)) + }, + }, + { + name: command.home, + title: `${input.label} go home`, + category: "Plugin", + namespace: "palette", + enabled: () => api.route.current.name !== "home", + run() { + api.route.navigate("home") + }, + }, + { + name: command.toast, + title: `${input.label} toast`, + category: "Plugin", + namespace: "palette", + run() { + api.ui.toast({ + variant: "info", + title: "Smoke", + message: "Plugin toast works", + duration: 2000, + }) + }, + }, + ], + bindings: keys.gather("smoke.global", [ + command.modal, + command.screen, + command.alert, + command.confirm, + command.prompt, + command.select, + command.host, + command.home, + command.toast, + ]), + }) +} + +const tui: TuiPlugin = async (api, options, meta) => { + if (options?.enabled === false) return + + await api.theme.install("./smoke-theme.json") + api.theme.set("smoke-theme") + + const value = cfg(options) + const route = names(value) + const keys = createKeys(value.keybinds) + const fx = new VignetteEffect(value.vignette) + const post = fx.apply.bind(fx) + api.renderer.addPostProcessFn(post) + api.lifecycle.onDispose(() => { + api.renderer.removePostProcessFn(post) + }) + + api.route.register([ + { + name: route.screen, + render: ({ params }) => , + }, + { + name: route.modal, + render: ({ params }) => , + }, + ]) + + reg(api, value, keys) + for (const item of slot(api, value)) { + api.slots.register(item) + } +} + +const plugin: TuiPluginModule & { id: string } = { + id: "tui-smoke", + tui, +} + +export default plugin diff --git a/.teamcode/skills/annotation-system/SKILL.md b/.teamcode/skills/annotation-system/SKILL.md new file mode 100644 index 0000000..e2757ed --- /dev/null +++ b/.teamcode/skills/annotation-system/SKILL.md @@ -0,0 +1,129 @@ +--- +name: annotation-system +description: Use when working with vyx's annotation-based routing system. Covers @Route, @Auth, @Validate, @Page annotations, the static scanner (Go/TS/TSX/Python), route_map.json generation, and the RouteMap trie. Do NOT use for other topics. +--- + +# vyx Annotation System + +This skill documents the annotation-based routing system that is central to vyx. + +## Overview + +vyx uses **static annotations** in source code comments to declare routes, auth requirements, and validation schemas. The scanner (`scanner/`) parses these at build time and generates a `route_map.json` consumed by the Core. + +## Supported Annotations + +### `@Route(path, method?)` — Go, TypeScript, Python +Declares an API endpoint. + +``` +// @Route(/api/users) +// @Route(/api/users, POST) +``` + +| Lang | Parser | Example | +|----------|-----------------|---------| +| Go | `go_parser.go` | `// @Route(/api/users)` | +| TypeScript | `ts_parser.go` | `// @Route(/api/users)` | +| Python | `py_parser.go` | `# @Route(/api/users)` | + +### `@Auth(roles: [...])` — Go, TypeScript, Python +Restricts route to specific roles. + +``` +// @Route(/admin) +// @Auth(roles: ["admin", "superuser"]) +``` + +### `@Validate(schema)` — Go, TypeScript +Attaches a JSON Schema for request validation. + +``` +// @Route(/users) +// @Validate(createUserSchema) +``` + +### `@Page(path)` — TSX (React) +Declares a page route (always GET). + +``` +// @Page(/dashboard) +``` + +## Route Entry Model + +```go +type RouteEntry struct { + Path string // e.g., "/api/users/:id" + Method string // GET, POST, PUT, DELETE, PATCH + WorkerID string // "node:ssr", "python:api", "go:api" + AuthRoles []string // ["admin", "user"] or nil for public + Validate string // schema name or "" + Type string // "api" or "page" + File string // source file path + Line int // line number in source +} +``` + +## Scanner Architecture + +Each language has its own parser file in `scanner/`: + +``` +scanner/ +├── go_parser.go # Parses // @Route, // @Auth, // @Validate in .go files +├── ts_parser.go # Parses // @Route, // @Auth, // @Validate in .ts files +├── tsx_parser.go # Parses // @Page, // @Auth in .tsx files +├── py_parser.go # Parses # @Route, # @Auth in .py files +├── validator.go # Validates parsed routes for conflicts/duplicates +├── generator.go # Generates route_map.json from parsed routes +└── ..._test.go # Test files +``` + +### Adding a new annotation type +1. Add regex pattern in the relevant parser. +2. Update `RouteEntry` struct if new fields are needed. +3. Update `validator.go` to validate the new field. +4. Update `generator.go` to include the field in JSON output. + +## RouteMap Trie + +The `RouteMap` (in `core/domain/gateway/`) is a **trie** (prefix tree) that enables O(k) route matching where k is path segment count. + +### Hot-swap mechanism +- `RouteMap` uses `unsafe.Pointer` with `atomic.Load/Store` for lock-free reads. +- On `SIGHUP` or file change, a new `RouteMap` is built from `route_map.json` and atomically swapped. +- Old `RouteMap` is garbage-collected after all in-flight requests finish. + +### Route matching +``` +GET /api/users/123 +→ RouteMap.Match("GET", "/api/users/123") +→ Matches /api/users/:id with params {id: "123"} +``` + +### Wildcard segments +- `:param` — named parameter (matches one segment) +- `*` — catch-all (matches remainder of path) + +## workerID convention + +| Worker | Example workerID | +|--------|-----------------| +| Node.js SSR | `"node:ssr"` | +| Node.js API | `"node:api"` | +| Python API | `"python:api"` | +| Go API | `"go:api"` | + +## Testing the scanner + +```bash +cd scanner && go test -v -run "TestParse.*File" -count=1 +cd scanner && go test -v -run "TestValidator" -count=1 +``` + +Key test patterns: +- **Unreadable file**: use non-existent paths (NOT permission-based tests) +- **Error messages**: test `AnnotationError.Message` contains meaningful text +- **Route output**: test `Route.Path`, `Route.Method`, `Route.AuthRoles` +- **Edge cases**: empty path, missing annotation, multiple annotations per file diff --git a/.teamcode/skills/effect/SKILL.md b/.teamcode/skills/effect/SKILL.md new file mode 100644 index 0000000..3a44fa8 --- /dev/null +++ b/.teamcode/skills/effect/SKILL.md @@ -0,0 +1,38 @@ +--- +name: effect +description: Work with Effect v4 / effect-smol TypeScript code in this repo +--- + +# Effect + +This codebase uses Effect for typed, composable TypeScript services, schemas, and workflows. + +## Source Of Truth + +Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples. + +1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder. +2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code. +3. Also inspect existing repo code for local house style before introducing new patterns. +4. Prefer answers and implementations backed by specific source files or nearby repo examples. + +## Guidelines + +- Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses. +- Use `Effect.gen(function* () { ... })` for multi-step workflows. +- Use `Effect.fn("Name")` or `Effect.fnUntraced(...)` for named effects when adding reusable service methods or important workflows. +- Prefer Effect `Schema` for API and domain data shapes. Use branded schemas for IDs and `Schema.TaggedErrorClass` for typed domain errors when modeling new error surfaces. +- Keep HTTP handlers thin: decode input, read request context, call services, and map transport errors. Put business rules in services. +- In Effect service code, prefer Effect-aware platform abstractions and dependencies over ad hoc promises where the surrounding code already does so. +- Keep layer composition explicit. Avoid broad hidden provisioning that makes missing dependencies hard to see. +- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior. +- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types. +- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first. + +## Testing Patterns + +- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations. +- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior. +- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root. +- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file. +- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state. diff --git a/.teamcode/skills/go-vyx/SKILL.md b/.teamcode/skills/go-vyx/SKILL.md new file mode 100644 index 0000000..a60a051 --- /dev/null +++ b/.teamcode/skills/go-vyx/SKILL.md @@ -0,0 +1,144 @@ +--- +name: go-vyx +description: Use when writing or modifying Go code in the vyx Core Orchestrator or Scanner. Covers Clean Architecture layering, error handling, testing conventions, and project-specific patterns. Do NOT use for Node.js, Python, or infra code. +--- + +# Go Patterns in vyx + +This skill documents the Go conventions and patterns used across the vyx project. + +## Architecture — Clean Architecture in `core/` + +``` +core/ +├── domain/ # Entities, value objects, interfaces — ZERO external dependencies +├── application/ # Use cases, orchestration, business logic +├── infrastructure/ # I/O, frameworks, concrete implementations +``` + +### Layering rules + +- **domain/** imports nothing outside stdlib. Defines interfaces (`Repository`, `Manager`, `EventPublisher`) that `infrastructure/` implements. +- **application/** imports domain. Never imports infrastructure directly (Dependency Inversion). +- **infrastructure/** imports both domain and application to wire implementations. +- **Test files** in `infrastructure/` may test against concrete implementations using test doubles from `testutil/` when available. + +### Key domain packages + +| Package | Purpose | Key Types | +|---------|---------|-----------| +| `domain/circuit/` | Circuit Breaker state machine | `State` (Closed/Open/HalfOpen), `Breaker` | +| `domain/gateway/` | Route map + request/response | `RouteMap` (trie), `RouteEntry`, `GatewayRequest` | +| `domain/ipc/` | IPC message types | `MessageType` (0x01-0x08), `HandshakePayload` | +| `domain/worker/` | Worker entity | `State`, `Manager`, `Repository`, `EventPublisher` | +| `domain/pool/` | Worker pool | Pool configuration, round-robin | + +### Error handling + +- Use `fmt.Errorf("context: %w", err)` for error wrapping (Go 1.13+ style). +- Sentinel errors when the caller needs to distinguish: `var ErrNotFound = errors.New("not found")`. +- Always check errors. Never use `_` for error returns. +- Context cancellation: check `ctx.Err()` when appropriate. +- Custom error types only when callers need structured fields. + +## Testing conventions + +### General +- File: `*_test.go` in the **same package** (internal tests). +- Table-driven tests with `[]struct{ name string; ... }` and `t.Run(tc.name, ...)`. +- `testify/assert` for most assertions; `testify/require` for fatal conditions. +- Coverage: `gateway` package target ≥80%. + +### Patterns +```go +func TestSomething(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "valid input", input: "hello", want: "HELLO"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := MyFunc(tc.input) + assert.Equal(t, tc.want, got) + }) + } +} +``` + +- **Parallel tests**: `t.Parallel()` for independent test cases. +- **Subtests**: `t.Run("case name", ...)` for logical grouping. +- **Fixtures**: use `t.TempDir()` for temporary directories (auto-cleaned). +- **Race detector**: always run with `-race`. +- **Integration tests**: build tag `//go:build integration` in `core/integration/`. + +### Mocks +- Prefer **interfaces** over concrete types for testability. +- Hand-written test doubles (no mock frameworks). +- Example pattern in `domain/worker/` where `Repository` interface is implemented in-memory. + +## Scanner conventions (`scanner/`) + +The scanner is a **pure Go** package that statically analyzes source files for annotations. + +### Adding a new parser +1. Create `scanner/_parser.go` with: + - Regex patterns for annotation matching + - A `parseFile(path, workerID)` function + - A `ParseFiles(dir, workerID)` walk function +2. Register in `scanner/validator.go` for validation. +3. Register in `scanner/generator.go` for route_map.json generation. +4. Add tests in `scanner/_parser_test.go`. + +### Annotation error reporting +```go +type AnnotationError struct { + File string + Line int + Message string +} +``` + +### File-open error pattern +When `os.Open` fails, return the error — never silently return `nil`: +```go +f, err := os.Open(path) +if err != nil { + return nil, []AnnotationError{{ + File: path, Line: 0, + Message: fmt.Sprintf("cannot open file: %v", err), + }} +} +``` + +For tests that exercise the file-open error path, use non-existent paths (NOT permission-based tests): +```go +// BAD: os.WriteFile(path, data, 0000) — root bypasses permissions +// GOOD: routes, errs := ParseTSFiles("/nonexistent/path", ...) +``` + +## CLI conventions (`cmd/vyx/`) + +- Each subcommand (dev, build, new, annotate) gets its own file or group. +- CLI uses Cobra — register new commands in `cmd/vyx/` following existing patterns. +- User-facing errors should be descriptive and actionable. + +## Build & run + +```bash +# Build everything +cd core && go build ./... + +# Run all tests with race detection +go test ./... -race -count=1 -coverprofile=coverage.txt + +# Lint +golangci-lint run + +# Security scan +govulncheck ./... + +# Run specific package tests +go test ./domain/... ./application/... ./infrastructure/... diff --git a/.teamcode/skills/improve-codebase-architecture/DEEPENING.md b/.teamcode/skills/improve-codebase-architecture/DEEPENING.md new file mode 100644 index 0000000..c52fdfd --- /dev/null +++ b/.teamcode/skills/improve-codebase-architecture/DEEPENING.md @@ -0,0 +1,37 @@ +# Deepening + +How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**. + +## Dependency categories + +When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam. + +### 1. In-process + +Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed. + +### 2. Local-substitutable + +Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface. + +### 3. Remote but owned (Ports & Adapters) + +Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter. + +Recommendation shape: _"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."_ + +### 4. True external (Mock) + +Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter. + +## Seam discipline + +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection. +- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them. + +## Testing strategy: replace, don't layer + +- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them. +- Write new tests at the deepened module's interface. The **interface is the test surface**. +- Tests assert on observable outcomes through the interface, not internal state. +- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface. diff --git a/.teamcode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md b/.teamcode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md new file mode 100644 index 0000000..3197723 --- /dev/null +++ b/.teamcode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md @@ -0,0 +1,44 @@ +# Interface Design + +When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best. + +Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**. + +## Process + +### 1. Frame the problem space + +Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate: + +- The constraints any new interface would need to satisfy +- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md)) +- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete + +Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel. + +### 2. Spawn sub-agents + +Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module. + +Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint: + +- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point." +- Agent 2: "Maximise flexibility — support many use cases and extension." +- Agent 3: "Optimise for the most common caller — make the default case trivial." +- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies." + +Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language. + +Each sub-agent outputs: + +1. Interface (types, methods, params — plus invariants, ordering, error modes) +2. Usage example showing how callers use it +3. What the implementation hides behind the seam +4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md)) +5. Trade-offs — where leverage is high, where it's thin + +### 3. Present and compare + +Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**. + +After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu. diff --git a/.teamcode/skills/improve-codebase-architecture/LANGUAGE.md b/.teamcode/skills/improve-codebase-architecture/LANGUAGE.md new file mode 100644 index 0000000..dd9b60f --- /dev/null +++ b/.teamcode/skills/improve-codebase-architecture/LANGUAGE.md @@ -0,0 +1,53 @@ +# Language + +Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point. + +## Terms + +**Module** +Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice. +_Avoid_: unit, component, service. + +**Interface** +Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. +_Avoid_: API, signature (too narrow — those refer only to the type-level surface). + +**Implementation** +What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise. + +**Depth** +Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation. + +**Seam** _(from Michael Feathers)_ +A place where you can alter behaviour without editing in that place. The _location_ at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. +_Avoid_: boundary (overloaded with DDD's bounded context). + +**Adapter** +A concrete thing that satisfies an interface at a seam. Describes _role_ (what slot it fills), not substance (what's inside). + +**Leverage** +What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests. + +**Locality** +What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere. + +## Principles + +- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface. +- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep. +- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test _past_ the interface, the module is probably the wrong shape. +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. + +## Relationships + +- A **Module** has exactly one **Interface** (the surface it presents to callers and tests). +- **Depth** is a property of a **Module**, measured against its **Interface**. +- A **Seam** is where a **Module**'s **Interface** lives. +- An **Adapter** sits at a **Seam** and satisfies the **Interface**. +- **Depth** produces **Leverage** for callers and **Locality** for maintainers. + +## Rejected framings + +- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead. +- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know. +- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**. diff --git a/.teamcode/skills/improve-codebase-architecture/SKILL.md b/.teamcode/skills/improve-codebase-architecture/SKILL.md new file mode 100644 index 0000000..05984a6 --- /dev/null +++ b/.teamcode/skills/improve-codebase-architecture/SKILL.md @@ -0,0 +1,71 @@ +--- +name: improve-codebase-architecture +description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable. +--- + +# Improve Codebase Architecture + +Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. + +## Glossary + +Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md). + +- **Module** — anything with an interface and an implementation (function, class, package, slice). +- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature. +- **Implementation** — the code inside. +- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation. +- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.") +- **Adapter** — a concrete thing satisfying an interface at a seam. +- **Leverage** — what callers get from depth. +- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. + +Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list): + +- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. +- **The interface is the test surface.** +- **One adapter = hypothetical seam. Two adapters = real seam.** + +This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate. + +## Process + +### 1. Explore + +Read the project's domain glossary and any ADRs in the area you're touching first. + +Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: + +- Where does understanding one concept require bouncing between many small modules? +- Where are modules **shallow** — interface nearly as complex as the implementation? +- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)? +- Where do tightly-coupled modules leak across their seams? +- Which parts of the codebase are untested, or hard to test through their current interface? + +Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want. + +### 2. Present candidates + +Present a numbered list of deepening opportunities. For each candidate: + +- **Files** — which files/modules are involved +- **Problem** — why the current architecture is causing friction +- **Solution** — plain English description of what would change +- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve + +**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." + +**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. + +Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?" + +### 3. Grilling loop + +Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive. + +Side effects happen inline as decisions crystallize: + +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist. +- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md). +- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). diff --git a/.teamcode/skills/ipc-protocol/SKILL.md b/.teamcode/skills/ipc-protocol/SKILL.md new file mode 100644 index 0000000..7bfef99 --- /dev/null +++ b/.teamcode/skills/ipc-protocol/SKILL.md @@ -0,0 +1,133 @@ +--- +name: ipc-protocol +description: Use when working with vyx's IPC layer — Unix Domain Sockets, binary framing protocol, MsgPack serialization, Apache Arrow, handshake, heartbeat, and request/response message flow between Core and Workers. +--- + +# vyx IPC Protocol + +This skill documents the inter-process communication layer between the Go Core Orchestrator and workers. + +## Architecture + +``` +[Core (Go)] ←→ UDS Socket Pair ←→ [Worker (Go/Node/Python)] + ↑ ↑ + cmd/req resp/stream +``` + +- Each worker gets a **pair of Unix Domain Sockets** (one for commands/requests, one for responses/streams). +- Core listens on each socket. Workers connect as clients. +- On Windows: Named Pipes instead of UDS. + +## Binary Frame Protocol + +Messages follow a simple binary format: + +``` +┌────────────┬──────────┬──────────────────┐ +│ Length │ Type │ Payload │ +│ (4 bytes) │ (1 byte) │ (Length bytes) │ +│ big-endian│ │ │ +└────────────┴──────────┴──────────────────┘ +``` + +Total header: 5 bytes. Payload is `Length` bytes. + +### Message Types + +| Code | Type | Direction | Description | +|------|------|-----------|-------------| +| 0x01 | Request | Core → Worker | Incoming HTTP request to process | +| 0x02 | Response | Worker → Core | HTTP response from worker | +| 0x03 | Heartbeat | Worker → Core | Liveness ping (every 5s) | +| 0x04 | Error | Bidirectional | Error notification | +| 0x05 | Handshake | Worker → Core | Worker registration | +| 0x06 | WS_Open | Core → Worker | WebSocket connection opened | +| 0x07 | WS_Message | Core → Worker | WebSocket message | +| 0x08 | WS_Close | Core → Worker | WebSocket connection closed | + +## Serialization + +### Small payloads — MessagePack +For requests, responses, handshakes, heartbeats, and errors. +- Go: `github.com/vmihailenco/msgpack/v5` +- Node: `@msgpack/msgpack` +- Python: `msgpack` + +### Large datasets — Apache Arrow +For tabular data or payloads > ~64KB. +- Go: `github.com/apache/arrow/go/v18/arrow` +- Supported by codec in `core/infrastructure/ipc/codec/` + +### Selector +`core/infrastructure/ipc/codec/selector.go` — routes between MsgPack and Arrow based on payload heuristics. + +## Handshake Protocol + +When a worker connects to the Core: + +1. Worker connects to both UDS sockets. +2. Worker sends `HandshakePayload` (0x05) containing: + - `worker_id` — unique identifier (e.g., `"node:ssr"`) + - `technology` — `"go"`, `"node"`, or `"python"` + - `version` — SDK version +3. Core validates, registers worker, assigns to pool. +4. Core replies with acknowledgment. + +## Heartbeat + +- Workers send a heartbeat (0x03) every **5 seconds**. +- Core tracks last heartbeat time per worker. +- If no heartbeat for **15 seconds**, worker is marked unhealthy. +- Unhealthy workers are restarted after configured timeout. + +## Request/Response Flow + +``` +Client Core Worker + │ │ │ + │── HTTP Request ───────►│ │ + │ │── 0x01 Request (UDS) ─────►│ + │ │ │── Process + │ │◄── 0x02 Response (UDS) ────│ + │◄── HTTP Response ──────│ │ +``` + +1. Core receives HTTP request +2. Core matches route in RouteMap trie +3. Core picks worker from pool (round-robin) +4. Core serializes request via MsgPack, sends over UDS +5. Worker deserializes, processes, sends response +6. Core deserializes response, sends HTTP response to client + +## Streams + +For long-lived streams (Server-Sent Events, large responses): +- Worker writes frames of type 0x02 sequentially over the response socket. +- Each frame carries a sequence number and an optional continuation flag. + +## Cross-platform support + +- **Unix**: UDS in `core/infrastructure/ipc/uds/` — `client.go`, `listener.go` +- **Windows**: Named pipes in `core/infrastructure/ipc/uds/` — `named_pipe_windows.go` (build tag `windows`) +- **Build tags**: Files use `//go:build !windows` and `//go:build windows` to select implementation. + +## Key packages + +| Package | Description | +|---------|-------------| +| `domain/ipc/` | Message types, HandshakePayload (no deps) | +| `infrastructure/ipc/codec/` | MsgPack + Arrow + Selector | +| `infrastructure/ipc/framing/` | Binary frame read/write | +| `infrastructure/ipc/uds/` | UDS client + listener (cross-platform) | +| `infrastructure/ipc/shm/` | Shared memory (mmap) | + +## Testing IPC + +```bash +# Run all IPC tests +cd core && go test ./infrastructure/ipc/... -v -count=1 + +# Run with race detection +cd core && go test ./infrastructure/ipc/... -race -count=1 +``` diff --git a/.teamcode/skills/node-worker-sdk/SKILL.md b/.teamcode/skills/node-worker-sdk/SKILL.md new file mode 100644 index 0000000..56c11ae --- /dev/null +++ b/.teamcode/skills/node-worker-sdk/SKILL.md @@ -0,0 +1,111 @@ +--- +name: node-worker-sdk +description: Use when writing or modifying TypeScript/JavaScript code in the vyx Node.js Worker SDK (packages/worker/). Covers @vyx/worker package, IPC client, dispatch, context, testing with vitest, ESLint config. Do NOT use for Go core or Python worker. +--- + +# vyx Node.js Worker SDK + +This skill documents the Node.js/TypeScript worker SDK at `packages/worker/`. + +## Package structure + +``` +packages/worker/ +├── src/ +│ ├── index.ts # Entry point — exports public API +│ ├── dispatch.ts # Request dispatcher (routes incoming requests) +│ ├── request.ts # Request helper utilities +│ └── context.ts # Correlation context (AsyncLocalStorage) +├── tests/ +│ ├── dispatch.test.ts +│ ├── request.test.ts +│ ├── context.test.ts +│ └── index.test.ts +├── package.json # @vyx/worker package +├── tsconfig.json # TypeScript strict mode +├── eslint.config.js # ESLint flat config (v10+) +└── vitest.config.ts # Vitest configuration +``` + +## Key patterns + +### IPC Client +- Connects to Core via Unix Domain Socket. +- Node.js `net` module for UDS connections. +- Implements the binary framing protocol (length + type + payload). +- Uses `@msgpack/msgpack` for serialization. + +### Dispatch (`dispatch.ts`) +- Listens on IPC socket for incoming requests. +- Matches routes to handler functions. +- Returns responses via IPC. + +### Context (`context.ts`) +- Uses Node.js `AsyncLocalStorage` for correlation ID propagation. +- Async-scoped context, no global state. +- Propagates tracing headers from Core. + +### Request (`request.ts`) +- Parses incoming request from Core (method, path, headers, body). +- Provides typed helpers for response construction. + +## Testing + +```bash +cd packages/worker + +# Run all tests +npm test + +# Or with vitest directly +npx vitest run + +# With coverage +npx vitest run --coverage + +# Watch mode +npx vitest + +# Lint +npm run lint +``` + +### Test patterns +- Use `vitest` with `describe`/`it` blocks. +- Mock UDS connections with in-memory streams. +- Use `vi.mock()` for module-level mocking. + +```ts +import { describe, it, expect, vi } from 'vitest' + +describe('dispatch', () => { + it('should route to correct handler', () => { + // ... + expect(result).toEqual(expected) + }) +}) +``` + +## ESLint +- Flat config in `eslint.config.js` (ESLint v10). +- Uses `typescript-eslint` for TS-specific rules. +- Run: `npm run lint` + +## TypeScript config +- `strict: true` in tsconfig.json. +- `target: ES2022` or later. +- `moduleResolution: bundler` or `node16`. + +## Adding new features + +1. Export new types/functions from `src/index.ts`. +2. Add tests in `tests/`. +3. Update `package.json` exports if needed. +4. Verify: `cd packages/worker && npm test && npm run lint` + +## Conventions +- camelCase for variables/functions. +- PascalCase for types/interfaces/classes. +- Prefer `const` over `let`, avoid `var`. +- Async/await over raw promises where possible. +- Error handling with typed error classes. diff --git a/.teamcode/skills/python-worker-sdk/SKILL.md b/.teamcode/skills/python-worker-sdk/SKILL.md new file mode 100644 index 0000000..51dc269 --- /dev/null +++ b/.teamcode/skills/python-worker-sdk/SKILL.md @@ -0,0 +1,88 @@ +--- +name: python-worker-sdk +description: Use when writing or modifying Python code in the vyx Python Worker SDK (packages/python/). Covers the vyx package, IPC client, annotation scanner, dispatch logic, testing with pytest. Do NOT use for Go core or Node.js worker. +--- + +# vyx Python Worker SDK + +This skill documents the Python worker SDK at `packages/python/`. + +## Package structure + +``` +packages/python/ +├── vyx/ +│ ├── __init__.py +│ ├── cli.py # CLI entry point (vyx scan) +│ ├── ipc.py # UDS client + IPC protocol +│ ├── dispatch.py # Request dispatcher +│ ├── context.py # Correlation ID via ContextVars +│ ├── validate.py # Pydantic validation helpers +│ └── scanner.py # Python annotation scanner (# @Route, # @Auth) +├── tests/ +│ ├── test_ipc.py +│ ├── test_dispatch.py +│ ├── test_context.py +│ ├── test_scanner.py +│ └── test_validate.py +├── pyproject.toml # or setup.py — package configuration +└── requirements.txt # or pyproject.toml deps +``` + +## Key patterns + +### IPC Client (`ipc.py`) +- Connects to Core via Unix Domain Socket. +- Implements the binary framing protocol (length + type + payload). +- Serializes/deserializes with `msgpack`. +- Handles reconnection with exponential backoff. + +### Dispatch (`dispatch.py`) +- Receives requests from Core via IPC. +- Routes to the appropriate handler function. +- Returns response via IPC. + +### Context (`context.py`) +- Uses `contextvars.ContextVar` for correlation ID propagation. +- Request-scoped context, no global state. + +### Scanner (`scanner.py`) +- Static analysis of Python files for `# @Route` and `# @Auth` annotations. +- Mirrors the Go scanner for consistency. +- Used by `vyx scan` CLI command. + +### Validation (`validate.py`) +- Pydantic-based schema validation. +- Request body validation, response serialization. + +## Adding new features + +1. Add tests first (`tests/test_*.py`). +2. Implement in `vyx/`. +3. Update `__init__.py` exports if needed. +4. Verify: `cd packages/python && pip install -e . && python -m pytest tests/ -v` + +## Testing + +```bash +cd packages/python + +# Install in dev mode +pip install -e . + +# Run all tests +python -m pytest tests/ -v + +# With coverage +python -m pytest tests/ --cov=vyx -v + +# Specific test file +python -m pytest tests/test_scanner.py -v +``` + +## Code style + +- Follow PEP 8 (enforced by `ruff`). +- Type hints for all function signatures. +- Docstrings for public APIs. +- Async support for non-blocking operations. diff --git a/.teamcode/themes/mytheme.json b/.teamcode/themes/mytheme.json new file mode 100644 index 0000000..0e6b948 --- /dev/null +++ b/.teamcode/themes/mytheme.json @@ -0,0 +1,223 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "nord0": "#2E3440", + "nord1": "#3B4252", + "nord2": "#434C5E", + "nord3": "#4C566A", + "nord4": "#D8DEE9", + "nord5": "#E5E9F0", + "nord6": "#ECEFF4", + "nord7": "#8FBCBB", + "nord8": "#88C0D0", + "nord9": "#81A1C1", + "nord10": "#5E81AC", + "nord11": "#BF616A", + "nord12": "#D08770", + "nord13": "#EBCB8B", + "nord14": "#A3BE8C", + "nord15": "#B48EAD" + }, + "theme": { + "primary": { + "dark": "nord8", + "light": "nord10" + }, + "secondary": { + "dark": "nord9", + "light": "nord9" + }, + "accent": { + "dark": "nord7", + "light": "nord7" + }, + "error": { + "dark": "nord11", + "light": "nord11" + }, + "warning": { + "dark": "nord12", + "light": "nord12" + }, + "success": { + "dark": "nord14", + "light": "nord14" + }, + "info": { + "dark": "nord8", + "light": "nord10" + }, + "text": { + "dark": "nord4", + "light": "nord0" + }, + "textMuted": { + "dark": "nord3", + "light": "nord1" + }, + "background": { + "dark": "nord0", + "light": "nord6" + }, + "backgroundPanel": { + "dark": "nord1", + "light": "nord5" + }, + "backgroundElement": { + "dark": "nord1", + "light": "nord4" + }, + "border": { + "dark": "nord2", + "light": "nord3" + }, + "borderActive": { + "dark": "nord3", + "light": "nord2" + }, + "borderSubtle": { + "dark": "nord2", + "light": "nord3" + }, + "diffAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffContext": { + "dark": "nord3", + "light": "nord3" + }, + "diffHunkHeader": { + "dark": "nord3", + "light": "nord3" + }, + "diffHighlightAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffHighlightRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffAddedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffContextBg": { + "dark": "nord1", + "light": "nord5" + }, + "diffLineNumber": { + "dark": "#abafb7", + "light": "textMuted" + }, + "diffAddedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "markdownText": { + "dark": "nord4", + "light": "nord0" + }, + "markdownHeading": { + "dark": "nord8", + "light": "nord10" + }, + "markdownLink": { + "dark": "nord9", + "light": "nord9" + }, + "markdownLinkText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCode": { + "dark": "nord14", + "light": "nord14" + }, + "markdownBlockQuote": { + "dark": "nord3", + "light": "nord3" + }, + "markdownEmph": { + "dark": "nord12", + "light": "nord12" + }, + "markdownStrong": { + "dark": "nord13", + "light": "nord13" + }, + "markdownHorizontalRule": { + "dark": "nord3", + "light": "nord3" + }, + "markdownListItem": { + "dark": "nord8", + "light": "nord10" + }, + "markdownListEnumeration": { + "dark": "nord7", + "light": "nord7" + }, + "markdownImage": { + "dark": "nord9", + "light": "nord9" + }, + "markdownImageText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCodeBlock": { + "dark": "nord4", + "light": "nord0" + }, + "syntaxComment": { + "dark": "nord3", + "light": "nord3" + }, + "syntaxKeyword": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxFunction": { + "dark": "nord8", + "light": "nord8" + }, + "syntaxVariable": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxString": { + "dark": "nord14", + "light": "nord14" + }, + "syntaxNumber": { + "dark": "nord15", + "light": "nord15" + }, + "syntaxType": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxOperator": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxPunctuation": { + "dark": "nord4", + "light": "nord0" + } + } +} diff --git a/.teamcode/tool/github-pr-search.ts b/.teamcode/tool/github-pr-search.ts new file mode 100644 index 0000000..dbd3835 --- /dev/null +++ b/.teamcode/tool/github-pr-search.ts @@ -0,0 +1,64 @@ +/// +import { tool } from "@opencode-ai/plugin" +async function githubFetch(endpoint: string, options: RequestInit = {}) { + const response = await fetch(`https://api.github.com${endpoint}`, { + ...options, + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + ...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers), + }, + }) + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`) + } + return response.json() +} + +interface PR { + title: string + html_url: string +} + +export default tool({ + description: `Use this tool to search GitHub pull requests by title and description. + +This tool searches PRs in the ElioNeto/vyx repository and returns LLM-friendly results including: +- PR number and title +- Author +- State (open/closed/merged) +- Labels +- Description snippet + +Use the query parameter to search for keywords that might appear in PR titles or descriptions.`, + args: { + query: tool.schema.string().describe("Search query for PR titles and descriptions"), + limit: tool.schema.number().describe("Maximum number of results to return").default(10), + offset: tool.schema.number().describe("Number of results to skip for pagination").default(0), + }, + async execute(args) { + const owner = "ElioNeto" + const repo = "vyx" + + const page = Math.floor(args.offset / args.limit) + 1 + const searchQuery = encodeURIComponent(`${args.query} repo:${owner}/${repo} type:pr state:open`) + const result = await githubFetch( + `/search/issues?q=${searchQuery}&per_page=${args.limit}&page=${page}&sort=updated&order=desc`, + ) + + if (result.total_count === 0) { + return `No PRs found matching "${args.query}"` + } + + const prs = result.items as PR[] + + if (prs.length === 0) { + return `No other PRs found matching "${args.query}"` + } + + const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n") + + return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}` + }, +}) diff --git a/.teamcode/tool/github-triage.ts b/.teamcode/tool/github-triage.ts new file mode 100644 index 0000000..ee8e6ab --- /dev/null +++ b/.teamcode/tool/github-triage.ts @@ -0,0 +1,60 @@ +/// +import { tool } from "@opencode-ai/plugin" + +const TEAM = { + core: ["ElioNeto"], + scanner: ["ElioNeto"], + workers: ["ElioNeto"], + infra: ["ElioNeto"], + docs: ["ElioNeto"], +} as const + +function pick(items: readonly T[]) { + return items[Math.floor(Math.random() * items.length)]! +} + +function getIssueNumber(): number { + const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10) + if (!issue) throw new Error("ISSUE_NUMBER env var not set") + return issue +} + +async function githubFetch(endpoint: string, options: RequestInit = {}) { + const response = await fetch(`https://api.github.com${endpoint}`, { + ...options, + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + ...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers), + }, + }) + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`) + } + return response.json() +} + +export default tool({ + description: `Use this tool to assign a GitHub issue. + +Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`, + args: { + team: tool.schema + .enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]]) + .describe("The owning team"), + }, + async execute(args) { + const issue = getIssueNumber() + const owner = "ElioNeto" + const repo = "vyx" + const assignee = pick(TEAM[args.team]) + + await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, { + method: "POST", + body: JSON.stringify({ assignees: [assignee] }), + }) + + return `Assigned @${assignee} from ${args.team} to issue #${issue}` + }, +}) diff --git a/.teamcode/tui.json b/.teamcode/tui.json new file mode 100644 index 0000000..b92e58d --- /dev/null +++ b/.teamcode/tui.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://opencode.ai/tui.json", + "plugin": [ + [ + "./plugins/tui-smoke.tsx", + { + "enabled": false, + "label": "workspace", + "keybinds": { + "smoke_modal": "ctrl+alt+m", + "smoke_screen": "ctrl+alt+o", + "smoke_screen_home": "escape,ctrl+shift+h", + "smoke_screen_modal": "ctrl+alt+m", + "smoke_dialog_close": "escape,q" + } + } + ] + ] +} diff --git a/AGENTS.md b/AGENTS.md index fb56b5a..7f0eeb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,18 @@ ## Projeto -> Nome do projeto, objetivo principal e contexto de negócio em 2-3 frases. +**vyx** — um framework full-stack poliglota de alta performance onde um Core Orchestrator em Go gerencia workers em Go, Node.js e Python. Roteamento baseado em anotações estáticas (@Route, @Auth, @Validate, @Page) que geram um route_map.json consumido pelo Core. ## Stack -> Liste as tecnologias principais: linguagem, framework, banco de dados, infra. -> Exemplo: Node.js 20 + TypeScript, Fastify, PostgreSQL, Docker. +- **Core**: Go 1.25, Clean Architecture (domain → application → infrastructure) +- **Workers**: Node.js (TypeScript, @vyx/worker), Python 3.12 (vyx package), Go +- **IPC**: Unix Domain Sockets + MsgPack + Apache Arrow (protocolo binário) +- **Scanner**: Go puro — parse estático de anotações em Go/TS/TSX/Python +- **HTTP Gateway**: JWT, JSON Schema, Rate Limiter, Circuit Breaker +- **CLI**: Cobra (Go) — dev, build, new, annotate +- **CI/CD**: GitHub Actions (12+ jobs), Docker multi-stage, SonarCloud, Codecov +- **TUI**: Bubble Tea (Go) para log tailing ## Regras gerais @@ -96,36 +102,63 @@ govulncheck ./... ## Comandos úteis -> Preencha com os comandos exatos do projeto. O agente usará estes comandos diretamente. - ```bash -# Instalar dependências -npm install - -# Rodar testes -npm test - -# Lint -npm run lint - -# Build -npm run build - -# Dev -npm run dev +# Core (Go) — build, test, lint +cd core && go build ./... +cd core && go test ./... -race -count=1 +cd core && go vet ./... +cd core && golangci-lint run +cd core && govulncheck ./... + +# Scanner (Go) +cd scanner && go test ./... -race -count=1 + +# Node.js Worker SDK +cd packages/worker && npm install +cd packages/worker && npm test +cd packages/worker && npm run lint + +# Python Worker SDK +cd packages/python && pip install -e . +cd packages/python && python -m pytest tests/ -v +cd packages/python && ruff check . + +# Workflow agent (local CI) +cd scripts && npm install && npx tsx workflow-agent.ts + +# Check task state +cd scripts && npx tsx check-todos.ts + +# CLI +cd core && go build -o ../bin/vyx ./cmd/vyx +./bin/vyx dev # start dev mode +./bin/vyx build # build project +./bin/vyx annotate # scan annotations +./bin/vyx new # scaffold project ``` ## Convenções -> Preencha com as convenções do projeto. - -- **Commits**: Conventional Commits (`feat`, `fix`, `chore`, `docs`, `refactor`) +- **Commits**: Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`, `perf:`) +- **Scopes**: `core`, `scanner`, `worker`, `python`, `cli`, `infra`, `docs` - **Branches**: `feat/`, `fix/`, `chore/` -- **Naming**: camelCase para variáveis/funções, PascalCase para classes/tipos -- **Testes**: arquivos `*.test.ts` ao lado do módulo testado -- **Estrutura de pastas**: descreva aqui +- **Go naming**: camelCase vars/funcs, PascalCase exports, interfaces suffix `-er` +- **Testes Go**: `*_test.go` mesmo pacote, table-driven tests, testify +- **Testes Node**: `vitest`, arquivos `*.test.ts` +- **Testes Python**: `pytest`, arquivos `test_*.py` +- **Estrutura**: Clean Architecture em `core/` (domain → application → infrastructure) ## Contexto de domínio -> Glossário de termos do negócio que o agente precisa entender para implementar corretamente. -> Exemplo: "Pedido" = entidade central; "Fulfillment" = processo de separação e envio. +| Termo | Definição | +|-------|-----------| +| **Core** | Processo Go que orquestra tudo (HTTP gateway + worker manager) | +| **Worker** | Processo filho (Go/Node/Python) que executa lógica de negócio | +| **RouteMap** | Trie de rotas construída a partir de route_map.json (hot-swappable) | +| **RouteEntry** | Path + Method + WorkerID + AuthRoles + Validate + Type | +| **Annotation** | @Route, @Auth, @Validate, @Page em comentários de código fonte | +| **Circuit Breaker** | Máquina de estados (Closed → Open → Half-Open) por rota | +| **Worker Pool** | Múltiplos réplicas do mesmo worker com round-robin | +| **UDS** | Unix Domain Sockets para IPC core-worker | +| **Handshake** | Protocolo de registro do worker ao conectar | +| **Heartbeat** | Ping periódico (5s) do worker para o core | diff --git a/cmd/vyx/cmd_build.go b/cmd/vyx/cmd_build.go index 261c6cb..dce5c95 100644 --- a/cmd/vyx/cmd_build.go +++ b/cmd/vyx/cmd_build.go @@ -24,7 +24,7 @@ func runBuild(args []string) { fmt.Printf("\u2705 route_map.json written to %s\n", *output) fmt.Println("\U0001f527 Building core binary...") - if err := runCommand("go", "build", "-o", ".vyx/core", "./core/cmd/vyx"); err != nil { + if err := runCommand("go", "build", "-buildvcs=false", "-o", ".vyx/core", "github.com/ElioNeto/vyx/core/cmd/vyx"); err != nil { fmt.Fprintf(os.Stderr, "error: go build failed: %v\n", err) os.Exit(1) } diff --git a/cmd/vyx/cmd_dev.go b/cmd/vyx/cmd_dev.go index e6a8045..f9036d9 100644 --- a/cmd/vyx/cmd_dev.go +++ b/cmd/vyx/cmd_dev.go @@ -54,7 +54,7 @@ func runDev(args []string) { // Build the core binary first so we run the compiled version. fmt.Println("🔧 Building core...") - build := exec.Command("go", "build", "-o", ".vyx/core", "./core/cmd/vyx") + build := exec.Command("go", "build", "-buildvcs=false", "-o", ".vyx/core", "github.com/ElioNeto/vyx/core/cmd/vyx") build.Stdout = os.Stdout build.Stderr = os.Stderr if err := build.Run(); err != nil { diff --git a/core/application/gateway/dispatcher.go b/core/application/gateway/dispatcher.go index fb595e2..965da41 100644 --- a/core/application/gateway/dispatcher.go +++ b/core/application/gateway/dispatcher.go @@ -407,7 +407,6 @@ func (d *Dispatcher) sendAndReceive(ctx context.Context, req *dgw.GatewayRequest if err != nil { d.releaseInFlight(lc) return d.handleReceiveError(dispatchCtx, receiveErrorConfig{ - Ctx: ctx, Req: req, Route: &dgw.RouteEntry{WorkerID: workerID}, Lc: lc, @@ -512,7 +511,6 @@ func (d *Dispatcher) handleSendError(ctx context.Context, req *dgw.GatewayReques // receiveErrorConfig holds parameters for handleReceiveError. type receiveErrorConfig struct { - Ctx context.Context Req *dgw.GatewayRequest Route *dgw.RouteEntry Lc *LifecycleContext @@ -535,7 +533,7 @@ func (d *Dispatcher) handleReceiveError(ctx context.Context, cfg receiveErrorCon cfg.Lc.Phase = PhasePostDispatch } for _, hook := range d.hooks { - hook.OnWorkerError(cfg.Ctx, cfg.Route.WorkerID, cfg.Req, cfg.Lc.Err) + hook.OnWorkerError(ctx, cfg.Route.WorkerID, cfg.Req, cfg.Lc.Err) } cfg.Cb.RecordFailure() return nil, nil, false diff --git a/core/application/gateway/dispatcher_circuit_test.go b/core/application/gateway/dispatcher_circuit_test.go index dfebd58..834d0b1 100644 --- a/core/application/gateway/dispatcher_circuit_test.go +++ b/core/application/gateway/dispatcher_circuit_test.go @@ -26,9 +26,8 @@ func (m *mockTransportForCircuit) Send(_ context.Context, _ string, _ ipc.Messag m.sendCount++ return m.sendErr } -func (m *mockTransportForCircuit) Receive(_ context.Context, _ string) (ipc.Message, error) { - m.recvCount++ - return m.recvMsg, m.recvErr +func (m *mockTransportForCircuit) Receive(ctx context.Context, workerID string) (ipc.Message, error) { + return m.ReceiveResponse(ctx, workerID) } func (m *mockTransportForCircuit) ReceiveResponse(_ context.Context, _ string) (ipc.Message, error) { m.recvCount++ diff --git a/core/application/lifecycle/service.go b/core/application/lifecycle/service.go index a407892..6490239 100644 --- a/core/application/lifecycle/service.go +++ b/core/application/lifecycle/service.go @@ -209,6 +209,26 @@ func (s *Service) StopAll(ctx context.Context) error { return lastErr } +// RestartAll gracefully restarts all alive workers with zero downtime. +// Each worker is drained before restart so no in-flight requests are dropped. #10 +func (s *Service) RestartAll(ctx context.Context) error { + workers, err := s.repo.FindAll(ctx) + if err != nil { + return err + } + + var lastErr error + for _, w := range workers { + if !w.IsAlive() { + continue + } + if err := s.RestartWorker(ctx, w.ID); err != nil { + lastErr = err + } + } + return lastErr +} + // RecordHeartbeat updates the last heartbeat timestamp for a worker. func (s *Service) RecordHeartbeat(ctx context.Context, id string) error { w, err := s.repo.FindByID(ctx, id) diff --git a/core/cmd/vyx/main.go b/core/cmd/vyx/main.go index 0cd72e6..adf1783 100644 --- a/core/cmd/vyx/main.go +++ b/core/cmd/vyx/main.go @@ -34,6 +34,7 @@ import ( dgw "github.com/ElioNeto/vyx/core/domain/gateway" "github.com/ElioNeto/vyx/core/domain/circuit" "github.com/ElioNeto/vyx/core/domain/ipc" + "github.com/ElioNeto/vyx/core/domain/pool" dlog "github.com/ElioNeto/vyx/core/domain/log" infracfg "github.com/ElioNeto/vyx/core/infrastructure/config" infragw "github.com/ElioNeto/vyx/core/infrastructure/gateway" @@ -584,10 +585,11 @@ func runServer(devMode, withTUI bool) { transport := setupTransport(cfg, log) repo, drainer, manager, publisher := setupCoreServices(mux.mux, log) - hbReceiver, service, healthMonitor := setupLifecycleServices(transport, repo, manager, publisher, drainer, log) + poolMgr := pool.NewManager(repo, manager) + hbReceiver, service, healthMonitor := setupLifecycleServices(transport, repo, manager, publisher, drainer, log, poolMgr) jwtValidator, schemaValidator := setupValidators(cfg, log) - dispatcher := setupDispatcher(rm, transport, jwtValidator, schemaValidator, cfg, drainer, log) + dispatcher := setupDispatcher(rm, transport, jwtValidator, schemaValidator, cfg, drainer, log, poolMgr) rateLimiter := setupRateLimiter(cfg) gwCfg := setupHTTPServerConfig(devMode) @@ -597,8 +599,7 @@ func runServer(devMode, withTUI bool) { ctx, stop := setupSignalHandling() defer stop() - startServices(startServicesConfig{ - Ctx: ctx, + startServices(ctx, startServicesConfig{ DevMode: devMode, Mux: mux.mux, Cfg: cfg, @@ -708,10 +709,11 @@ func setupLifecycleServices( publisher *logger.EventPublisher, drainer *lifecycle.WorkerDrainer, log *zap.Logger, + poolMgr *pool.Manager, ) (*heartbeat.Receiver, *lifecycle.Service, *monitor.Monitor) { hbCfg := heartbeat.DefaultConfig() hbReceiver := heartbeat.NewReceiver(transport, repo, nil, hbCfg, log) - service := lifecycle.NewService(repo, manager, publisher, transport, hbReceiver, drainer, nil) + service := lifecycle.NewService(repo, manager, publisher, transport, hbReceiver, drainer, poolMgr) hbReceiver.SetService(service) healthMonitor := monitor.New(service, repo) return hbReceiver, service, healthMonitor @@ -736,6 +738,7 @@ func setupDispatcher( cfg *doamincfg.Config, drainer *lifecycle.WorkerDrainer, log *zap.Logger, + poolMgr *pool.Manager, ) *apgw.Dispatcher { return apgw.NewDispatcher(apgw.DispatcherConfig{ Routes: rm, @@ -745,6 +748,7 @@ func setupDispatcher( Timeout: cfg.Security.GlobalTimeout, Log: log, Drainer: drainer, + PoolMgr: poolMgr, }, circuit.Config{ Failures: cfg.Security.CircuitBreaker.Failures, Cooldown: cfg.Security.CircuitBreaker.Cooldown, @@ -780,9 +784,26 @@ func setupSignalHandling() (context.Context, context.CancelFunc) { return ctx, stop } +// startSigUsr1Handler listens for SIGUSR1 and triggers zero-downtime reload. +// When SIGUSR1 is received, all workers are gracefully restarted without +// dropping in-flight requests (drain before stop). #10 +func startSigUsr1Handler(log *zap.Logger, service *lifecycle.Service) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGUSR1) + go func() { + for range sigCh { + log.Info("received SIGUSR1 — triggering zero-downtime worker reload") + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + if err := service.RestartAll(ctx); err != nil { + log.Error("SIGUSR1 reload: RestartAll failed", zap.Error(err)) + } + cancel() + } + }() +} + // startServicesConfig holds parameters for startServices to reduce parameter count. type startServicesConfig struct { - Ctx context.Context DevMode bool Mux *ilog.Multiplexer Cfg *doamincfg.Config @@ -798,7 +819,7 @@ type startServicesConfig struct { } // startServices starts all background services and spawns workers. -func startServices(cfg startServicesConfig) { +func startServices(ctx context.Context, cfg startServicesConfig) { if cfg.DevMode { cfg.Log.Info("vyx core starting in DEV mode", zap.String("addr", cfg.GwCfg.Addr)) } else { @@ -813,16 +834,19 @@ func startServices(cfg startServicesConfig) { }() } + // SIGUSR1 triggers zero-downtime reload of all workers (production) #10 + startSigUsr1Handler(cfg.Log, cfg.Service) + if cfg.DevMode { - go hotReloadWatcher(cfg.Ctx, os.Getenv("VYX_CONFIG"), cfg.Cfg.Workers, cfg.Service, cfg.Log) + go hotReloadWatcher(ctx, os.Getenv("VYX_CONFIG"), cfg.Cfg.Workers, cfg.Service, cfg.Log) } - spawnWorkers(cfg.Ctx, cfg.Cfg, cfg.Service, cfg.Transport, cfg.Log, cfg.HbReceiver) + spawnWorkers(ctx, cfg.Cfg, cfg.Service, cfg.Transport, cfg.Log, cfg.HbReceiver) - go cfg.HealthMonitor.Run(cfg.Ctx) - go cfg.CfgLoader.WatchSIGHUP(cfg.Ctx) - go cfg.HbSender.Run(cfg.Ctx) - go cfg.HbReceiver.Run(cfg.Ctx) + go cfg.HealthMonitor.Run(ctx) + go cfg.CfgLoader.WatchSIGHUP(ctx) + go cfg.HbSender.Run(ctx) + go cfg.HbReceiver.Run(ctx) go func() { var srvErr error @@ -856,8 +880,7 @@ func spawnWorker(ctx context.Context, wcfg doamincfg.WorkerConfig, service *life } for i := 0; i < replicas; i++ { workerID := buildWorkerID(wcfg.ID, i, replicas) - spawnWorkerInstance(spawnWorkerInstanceConfig{ - Ctx: ctx, + spawnWorkerInstance(ctx, spawnWorkerInstanceConfig{ WorkerID: workerID, Wcfg: wcfg, Service: service, @@ -879,7 +902,6 @@ func buildWorkerID(baseID string, index, replicas int) string { // spawnWorkerInstanceConfig holds parameters for spawnWorkerInstance. type spawnWorkerInstanceConfig struct { - Ctx context.Context WorkerID string Wcfg doamincfg.WorkerConfig Service *lifecycle.Service @@ -890,8 +912,8 @@ type spawnWorkerInstanceConfig struct { } // spawnWorkerInstance spawns a single worker instance. -func spawnWorkerInstance(cfg spawnWorkerInstanceConfig) { - if err := cfg.Transport.Register(cfg.Ctx, cfg.WorkerID); err != nil { +func spawnWorkerInstance(ctx context.Context, cfg spawnWorkerInstanceConfig) { + if err := cfg.Transport.Register(ctx, cfg.WorkerID); err != nil { cfg.Log.Error("failed to register IPC socket for worker", zap.String("worker_id", cfg.WorkerID), zap.Error(err)) return @@ -900,11 +922,11 @@ func spawnWorkerInstance(cfg spawnWorkerInstanceConfig) { cmd, cmdArgs := prepareWorkerCommand(cfg.Wcfg, cfg.WorkerID, cfg.SocketDir) workDir := resolveWorkerDir(cfg.Wcfg, os.Getenv("VYX_CONFIG")) - spawnCtx, spawnCancel := createSpawnContext(cfg.Ctx, cfg.Wcfg.StartupTimeout) + spawnCtx, spawnCancel := createSpawnContext(ctx, cfg.Wcfg.StartupTimeout) defer spawnCancel() vyxDir := getVyxDir() - w, err := cfg.Service.SpawnWorker(cfg.Ctx, lifecycle.SpawnWorkerConfig{ + w, err := cfg.Service.SpawnWorker(ctx, lifecycle.SpawnWorkerConfig{ ID: cfg.WorkerID, Command: cmd, Args: cmdArgs, @@ -927,7 +949,7 @@ func spawnWorkerInstance(cfg spawnWorkerInstanceConfig) { ) waitForWorkerHandshake(spawnCtx, cfg.WorkerID, cfg.Transport, cfg.Service, cfg.Log) - startWorkerHeartbeat(cfg.Ctx, w.ID, cfg.HbReceiver, cfg.Log) + startWorkerHeartbeat(ctx, w.ID, cfg.HbReceiver, cfg.Log) } // prepareWorkerCommand prepares the command and arguments for a worker. diff --git a/core/domain/ipc/codec.go b/core/domain/ipc/codec.go index 9051891..a725fb7 100644 --- a/core/domain/ipc/codec.go +++ b/core/domain/ipc/codec.go @@ -7,3 +7,21 @@ type Codec interface { Marshal(v any) ([]byte, error) Unmarshal(data []byte, v any) error } + +// TransferConfig controls how the IPC layer selects between different +// serialisation and transport strategies based on payload size. #7 +type TransferConfig struct { + // ArrowThreshold is the minimum payload size (in bytes) to use Arrow + // encoding instead of MsgPack. Defaults to 512 KB. + ArrowThreshold int `json:"arrow_threshold,omitempty" yaml:"arrow_threshold,omitempty"` + + // ArrowMMapThreshold is the minimum payload size (in bytes) to use + // shared-memory (mmap) transport instead of inline Arrow IPC bytes. + // Defaults to 4 MB. + ArrowMMapThreshold int `json:"arrow_mmap_threshold,omitempty" yaml:"arrow_mmap_threshold,omitempty"` + + // ArrowStreamingThreshold is the minimum payload size (in bytes) to + // split the data into streaming chunks rather than a single message. + // Defaults to 256 MB. + ArrowStreamingThreshold int `json:"arrow_streaming_threshold,omitempty" yaml:"arrow_streaming_threshold,omitempty"` +} diff --git a/core/domain/ipc/message.go b/core/domain/ipc/message.go index c28ed55..3585b15 100644 --- a/core/domain/ipc/message.go +++ b/core/domain/ipc/message.go @@ -24,6 +24,17 @@ const ( TypeWSMessage MessageType = 0x07 // TypeWSClose signals that a WebSocket session has ended (0x08). #19 TypeWSClose MessageType = 0x08 + + // TypeStreamStart signals the beginning of a streaming transfer (0x09). #7 + TypeStreamStart MessageType = 0x09 + // TypeStreamChunk carries a chunk of streaming data (0x0A). #7 + TypeStreamChunk MessageType = 0x0A + // TypeStreamEnd signals the end of a streaming transfer (0x0B). #7 + TypeStreamEnd MessageType = 0x0B + // TypeArrowData carries an Arrow IPC payload inline (0x0C). #7 + TypeArrowData MessageType = 0x0C + // TypeArrowSHM carries a shared-memory descriptor for Arrow zero-copy (0x0D). #7 + TypeArrowSHM MessageType = 0x0D ) // String returns a human-readable label for logging. @@ -45,6 +56,16 @@ func (t MessageType) String() string { return "ws_message" case TypeWSClose: return "ws_close" + case TypeStreamStart: + return "stream_start" + case TypeStreamChunk: + return "stream_chunk" + case TypeStreamEnd: + return "stream_end" + case TypeArrowData: + return "arrow_data" + case TypeArrowSHM: + return "arrow_shm" default: return fmt.Sprintf("unknown(0x%02x)", byte(t)) } diff --git a/core/domain/ipc/message_test.go b/core/domain/ipc/message_test.go index 32db847..0b5e764 100644 --- a/core/domain/ipc/message_test.go +++ b/core/domain/ipc/message_test.go @@ -19,6 +19,11 @@ func TestMessageType_String(t *testing.T) { {ipc.TypeWSOpen, "ws_open"}, {ipc.TypeWSMessage, "ws_message"}, {ipc.TypeWSClose, "ws_close"}, + {ipc.TypeStreamStart, "stream_start"}, + {ipc.TypeStreamChunk, "stream_chunk"}, + {ipc.TypeStreamEnd, "stream_end"}, + {ipc.TypeArrowData, "arrow_data"}, + {ipc.TypeArrowSHM, "arrow_shm"}, {ipc.MessageType(0xFF), "unknown(0xff)"}, } diff --git a/core/go.mod b/core/go.mod index 9aa03a1..896d532 100644 --- a/core/go.mod +++ b/core/go.mod @@ -9,7 +9,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 - github.com/stretchr/testify v1.8.1 + github.com/stretchr/testify v1.9.0 github.com/vmihailenco/msgpack/v5 v5.4.1 go.uber.org/zap v1.28.0 golang.org/x/net v0.54.0 @@ -18,6 +18,7 @@ require ( ) require ( + github.com/apache/arrow/go/v18 v18.0.0-20241007013041-ab95a4d25142 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect @@ -25,6 +26,8 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/google/flatbuffers v24.3.25+incompatible // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -37,5 +40,11 @@ require ( github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.10.0 // indirect + golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect ) diff --git a/core/go.sum b/core/go.sum index c7de1fe..b9c9260 100644 --- a/core/go.sum +++ b/core/go.sum @@ -1,3 +1,5 @@ +github.com/apache/arrow/go/v18 v18.0.0-20241007013041-ab95a4d25142 h1:6EtsUpu9/vLtVl6oVpFiZe9GRax7STd2bG55VNwsRdI= +github.com/apache/arrow/go/v18 v18.0.0-20241007013041-ab95a4d25142/go.mod h1:GjCnS5QddrJzyqrdYqCUvwlND7SfAw4WH/722M2U2NM= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -19,8 +21,12 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI= +github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -51,6 +57,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= @@ -67,14 +74,26 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= +golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/core/infrastructure/config/coverage_internal_test.go b/core/infrastructure/config/coverage_internal_test.go index 555c588..de03231 100644 --- a/core/infrastructure/config/coverage_internal_test.go +++ b/core/infrastructure/config/coverage_internal_test.go @@ -169,8 +169,7 @@ func TestLoad_InvalidYAML(t *testing.T) { path := filepath.Join(dir, "vyx.yaml") os.WriteFile(path, []byte("invalid: [yaml: broken"), 0644) loader := New(path, zap.NewNop()) - _, err := loader.Load() - if err == nil { + if _, err := loader.Load(); err == nil { t.Error("expected error for invalid YAML") } } diff --git a/core/infrastructure/config/loader_coverage_test.go b/core/infrastructure/config/loader_coverage_test.go index 0c75403..5d20e9f 100644 --- a/core/infrastructure/config/loader_coverage_test.go +++ b/core/infrastructure/config/loader_coverage_test.go @@ -10,14 +10,13 @@ import ( ) func TestLoader_WithRouteMap(t *testing.T) { - loader := infracfg.New("/tmp/test.yaml", nil) // Just verify it doesn't panic defer func() { if r := recover(); r != nil { t.Errorf("WithRouteMap panicked: %v", r) } }() - loader.WithRouteMap("/tmp/route_map.json", nil) + infracfg.New("/tmp/test.yaml", nil).WithRouteMap("/tmp/route_map.json", nil) } func TestLoader_CurrentAndLoad(t *testing.T) { @@ -32,26 +31,20 @@ project: t.Fatal(err) } - loader := infracfg.New(path, nil) - cfg, err := loader.Load() - if err != nil { + if cfg, err := infracfg.New(path, nil).Load(); err != nil { t.Fatalf("Load failed: %v", err) - } - - // Set current manually (since SetCurrent is not exported, we verify Load works) - if cfg.Project.Name != "test-app" { + } else if cfg.Project.Name != "test-app" { t.Errorf("Project.Name = %q, want %q", cfg.Project.Name, "test-app") } } func TestLoader_MustLoad(t *testing.T) { - loader := infracfg.New("/nonexistent.yaml", nil) defer func() { if r := recover(); r == nil { t.Error("MustLoad should panic on error") } }() - loader.MustLoad() + infracfg.New("/nonexistent.yaml", nil).MustLoad() } func TestLoader_LoadAndValidate(t *testing.T) { @@ -68,17 +61,15 @@ workers: t.Fatal(err) } - loader := infracfg.New(path, nil) - cfg, err := loader.Load() - if err != nil { + if cfg, err := infracfg.New(path, nil).Load(); err != nil { t.Fatalf("Load failed: %v", err) - } - - if len(cfg.Workers) != 1 { - t.Fatalf("expected 1 worker, got %d", len(cfg.Workers)) - } - if cfg.Workers[0].ID != "node:api" { - t.Errorf("Worker ID = %q, want %q", cfg.Workers[0].ID, "node:api") + } else { + if len(cfg.Workers) != 1 { + t.Fatalf("expected 1 worker, got %d", len(cfg.Workers)) + } + if cfg.Workers[0].ID != "node:api" { + t.Errorf("Worker ID = %q, want %q", cfg.Workers[0].ID, "node:api") + } } } @@ -134,13 +125,9 @@ ipc: t.Fatal(err) } - loader := infracfg.New(path, nil) - cfg, err := loader.Load() - if err != nil { + if cfg, err := infracfg.New(path, nil).Load(); err != nil { t.Fatalf("Load failed: %v", err) - } - - if cfg.IPC.SocketDir != "/tmp/custom-vyx" { + } else if cfg.IPC.SocketDir != "/tmp/custom-vyx" { t.Errorf("IPC.SocketDir = %q, want %q", cfg.IPC.SocketDir, "/tmp/custom-vyx") } } @@ -163,16 +150,14 @@ security: t.Fatal(err) } - loader := infracfg.New(path, nil) - cfg, err := loader.Load() - if err != nil { + if cfg, err := infracfg.New(path, nil).Load(); err != nil { t.Fatalf("Load failed: %v", err) - } - - if cfg.Security.JWTSecretEnv != "MY_SECRET" { - t.Errorf("JWTSecretEnv = %q, want %q", cfg.Security.JWTSecretEnv, "MY_SECRET") - } - if cfg.Security.GlobalTimeout != 45*time.Second { - t.Errorf("GlobalTimeout = %v, want 45s", cfg.Security.GlobalTimeout) + } else { + if cfg.Security.JWTSecretEnv != "MY_SECRET" { + t.Errorf("JWTSecretEnv = %q, want %q", cfg.Security.JWTSecretEnv, "MY_SECRET") + } + if cfg.Security.GlobalTimeout != 45*time.Second { + t.Errorf("GlobalTimeout = %v, want 45s", cfg.Security.GlobalTimeout) + } } } diff --git a/core/infrastructure/ipc/codec/arrow.go b/core/infrastructure/ipc/codec/arrow.go new file mode 100644 index 0000000..b06a337 --- /dev/null +++ b/core/infrastructure/ipc/codec/arrow.go @@ -0,0 +1,370 @@ +package codec + +import ( + "bytes" + "fmt" + "math" + "strconv" + + "github.com/apache/arrow/go/v18/arrow" + "github.com/apache/arrow/go/v18/arrow/array" + "github.com/apache/arrow/go/v18/arrow/ipc" + "github.com/apache/arrow/go/v18/arrow/memory" +) + +type ArrowCodec struct{} + +func (ArrowCodec) Marshal(v any) ([]byte, error) { + rows, err := toRows(v) + if err != nil { + return nil, fmt.Errorf("arrow: marshal: %w", err) + } + if len(rows) == 0 { + rec, err := emptyRecord() + if err != nil { + return nil, err + } + defer rec.Release() + return recordToIPC(rec) + } + + fields, cols := inferSchema(rows) + schema := arrow.NewSchema(fields, nil) + rec, err := buildRecord(schema, cols, len(rows)) + if err != nil { + return nil, fmt.Errorf("arrow: build record: %w", err) + } + defer rec.Release() + + return recordToIPC(rec) +} + +func (ArrowCodec) Unmarshal(data []byte, v any) error { + rec, err := ipcToRecord(data) + if err != nil { + return fmt.Errorf("arrow: unmarshal: %w", err) + } + defer rec.Release() + + rows := recordToRows(rec) + + out, ok := v.(*[]map[string]any) + if !ok { + return fmt.Errorf("arrow: unmarshal target must be *[]map[string]any") + } + *out = rows + return nil +} + +func toRows(v any) ([]map[string]any, error) { + switch val := v.(type) { + case []map[string]any: + return val, nil + case map[string]any: + return []map[string]any{val}, nil + default: + return nil, fmt.Errorf("unsupported type %T", v) + } +} + +func emptyRecord() (arrow.Record, error) { + schema := arrow.NewSchema([]arrow.Field{ + {Name: "_empty", Type: arrow.PrimitiveTypes.Int32}, + }, nil) + pool := memory.NewGoAllocator() + b := array.NewInt32Builder(pool) + defer b.Release() + b.AppendNull() + arr := b.NewArray() + defer arr.Release() + return array.NewRecord(schema, []arrow.Array{arr}, 0), nil +} + +func inferSchema(rows []map[string]any) ([]arrow.Field, [][]any) { + if len(rows) == 0 { + return nil, nil + } + keys := make([]string, 0, len(rows[0])) + seen := make(map[string]int) + for _, row := range rows { + for k := range row { + if _, ok := seen[k]; !ok { + seen[k] = len(keys) + keys = append(keys, k) + } + } + } + + fields := make([]arrow.Field, len(keys)) + cols := make([][]any, len(keys)) + for i, k := range keys { + dt := inferType(rows, k) + fields[i] = arrow.Field{Name: k, Type: dt, Nullable: true} + cols[i] = make([]any, len(rows)) + for j, row := range rows { + cols[i][j] = row[k] + } + } + return fields, cols +} + +func inferType(rows []map[string]any, key string) arrow.DataType { + for _, row := range rows { + v, ok := row[key] + if !ok || v == nil { + continue + } + switch v.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return arrow.PrimitiveTypes.Int64 + case float32, float64: + return arrow.PrimitiveTypes.Float64 + case bool: + return arrow.FixedWidthTypes.Boolean + case string: + return arrow.BinaryTypes.String + case []byte: + return arrow.BinaryTypes.Binary + default: + return arrow.BinaryTypes.String + } + } + return arrow.BinaryTypes.String +} + +func buildRecord(schema *arrow.Schema, cols [][]any, numRows int) (arrow.Record, error) { + pool := memory.NewGoAllocator() + columns := make([]arrow.Array, len(cols)) + + for i, col := range cols { + dt := schema.Field(i).Type + arr, err := buildColumn(pool, dt, col, numRows) + if err != nil { + return nil, fmt.Errorf("column %q: %w", schema.Field(i).Name, err) + } + columns[i] = arr + } + + return array.NewRecord(schema, columns, int64(numRows)), nil +} + +func buildColumn(pool memory.Allocator, dt arrow.DataType, col []any, numRows int) (arrow.Array, error) { + switch dt.ID() { + case arrow.INT64: + return buildInt64Column(pool, col, numRows) + case arrow.FLOAT64: + return buildFloat64Column(pool, col, numRows) + case arrow.BOOL: + return buildBoolColumn(pool, col, numRows) + case arrow.BINARY: + return buildBinaryColumn(pool, dt, col, numRows) + default: + return buildStringColumn(pool, col, numRows) + } +} + +func buildInt64Column(pool memory.Allocator, col []any, numRows int) (arrow.Array, error) { + b := array.NewInt64Builder(pool) + defer b.Release() + b.Resize(numRows) + for _, v := range col { + if v == nil { + b.AppendNull() + } else { + b.Append(toInt64(v)) + } + } + return b.NewArray(), nil +} + +func buildFloat64Column(pool memory.Allocator, col []any, numRows int) (arrow.Array, error) { + b := array.NewFloat64Builder(pool) + defer b.Release() + b.Resize(numRows) + for _, v := range col { + if v == nil { + b.AppendNull() + } else { + b.Append(toFloat64(v)) + } + } + return b.NewArray(), nil +} + +func buildBoolColumn(pool memory.Allocator, col []any, numRows int) (arrow.Array, error) { + b := array.NewBooleanBuilder(pool) + defer b.Release() + b.Resize(numRows) + for _, v := range col { + if v == nil { + b.AppendNull() + } else { + b.Append(v.(bool)) + } + } + return b.NewArray(), nil +} + +func buildBinaryColumn(pool memory.Allocator, dt arrow.DataType, col []any, numRows int) (arrow.Array, error) { + b := array.NewBinaryBuilder(pool, dt.(*arrow.BinaryType)) + defer b.Release() + b.Resize(numRows) + for _, v := range col { + if v == nil { + b.AppendNull() + } else { + b.Append(v.([]byte)) + } + } + return b.NewArray(), nil +} + +func buildStringColumn(pool memory.Allocator, col []any, numRows int) (arrow.Array, error) { + b := array.NewStringBuilder(pool) + defer b.Release() + b.Resize(numRows) + for _, v := range col { + if v == nil { + b.AppendNull() + } else { + b.Append(fmt.Sprintf("%v", v)) + } + } + return b.NewArray(), nil +} + +func toInt64(v any) int64 { + switch val := v.(type) { + case int: + return int64(val) + case int8: + return int64(val) + case int16: + return int64(val) + case int32: + return int64(val) + case int64: + return val + case uint: + return int64(val) + case uint8: + return int64(val) + case uint16: + return int64(val) + case uint32: + return int64(val) + case uint64: + return int64(val) + case float32: + return int64(val) + case float64: + return int64(val) + case string: + n, _ := strconv.ParseInt(val, 10, 64) + return n + default: + return 0 + } +} + +func toFloat64(v any) float64 { + switch val := v.(type) { + case float32: + return float64(val) + case float64: + return val + case int: + return float64(val) + case int8: + return float64(val) + case int16: + return float64(val) + case int32: + return float64(val) + case int64: + return float64(val) + case uint: + return float64(val) + case uint8: + return float64(val) + case uint16: + return float64(val) + case uint32: + return float64(val) + case uint64: + return float64(val) + case string: + n, _ := strconv.ParseFloat(val, 64) + return n + default: + return math.NaN() + } +} + +func recordToIPC(rec arrow.Record) ([]byte, error) { + var buf bytes.Buffer + w := ipc.NewWriter(&buf, ipc.WithSchema(rec.Schema())) + if err := w.Write(rec); err != nil { + return nil, fmt.Errorf("arrow: ipc write: %w", err) + } + if err := w.Close(); err != nil { + return nil, fmt.Errorf("arrow: ipc close: %w", err) + } + return buf.Bytes(), nil +} + +func ipcToRecord(data []byte) (arrow.Record, error) { + buf := memory.NewBufferBytes(data) + reader, err := ipc.NewReader(bytes.NewReader(buf.Bytes())) + if err != nil { + return nil, fmt.Errorf("arrow: ipc reader: %w", err) + } + + if !reader.Next() { + return nil, fmt.Errorf("arrow: no record in IPC data") + } + rec := reader.Record() + rec.Retain() + return rec, nil +} + +func recordToRows(rec arrow.Record) []map[string]any { + numRows := int(rec.NumRows()) + if numRows == 0 { + return nil + } + + rows := make([]map[string]any, numRows) + for i := range rows { + rows[i] = make(map[string]any) + } + + for j, col := range rec.Columns() { + name := rec.Schema().Field(j).Name + for i := 0; i < numRows; i++ { + if col.IsNull(i) { + rows[i][name] = nil + } else { + rows[i][name] = extractValue(col, i) + } + } + } + return rows +} + +func extractValue(col arrow.Array, i int) any { + switch arr := col.(type) { + case *array.Int64: + return arr.Value(i) + case *array.Float64: + return arr.Value(i) + case *array.Boolean: + return arr.Value(i) + case *array.String: + return arr.Value(i) + case *array.Binary: + return arr.Value(i) + default: + return fmt.Sprintf("%v", col.GetOneForMarshal(i)) + } +} diff --git a/core/infrastructure/ipc/codec/arrow_test.go b/core/infrastructure/ipc/codec/arrow_test.go new file mode 100644 index 0000000..53653e4 --- /dev/null +++ b/core/infrastructure/ipc/codec/arrow_test.go @@ -0,0 +1,265 @@ +package codec_test + +import ( + "fmt" + "testing" + + "github.com/ElioNeto/vyx/core/infrastructure/ipc/codec" +) + +// equal compares two values with tolerant numeric handling. +// Arrow serialises integers as int64, so we compare their string +// representations when the types differ but values are numerically equal. +func equal(got, want any) bool { + if got == want { + return true + } + // Handle numeric type mismatch (e.g. int vs int64) + if fmt.Sprint(got) == fmt.Sprint(want) { + return true + } + return false +} + +func TestArrowCodec_RoundTrip_MapSlice(t *testing.T) { + c := codec.ArrowCodec{} + + input := []map[string]any{ + {"name": "Alice", "age": 30, "active": true}, + {"name": "Bob", "age": 25, "active": false}, + {"name": "Charlie", "age": 35, "active": true}, + } + + data, err := c.Marshal(input) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var got []map[string]any + if err := c.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(got) != len(input) { + t.Fatalf("got %d rows, want %d", len(got), len(input)) + } + + for i := range input { + for k, wantVal := range input[i] { + gotVal, ok := got[i][k] + if !ok { + t.Errorf("row %d: missing key %q", i, k) + continue + } + if !equal(gotVal, wantVal) { + t.Errorf("row %d, key %q: want %v, got %v", i, k, wantVal, gotVal) + } + } + } +} + +func TestArrowCodec_RoundTrip_SingleMap(t *testing.T) { + c := codec.ArrowCodec{} + + input := map[string]any{ + "route": "/api/users", + "method": "GET", + "count": 42, + } + + data, err := c.Marshal(input) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var got []map[string]any + if err := c.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(got) != 1 { + t.Fatalf("got %d rows, want 1", len(got)) + } + + for k, wantVal := range input { + gotVal, ok := got[0][k] + if !ok { + t.Errorf("missing key %q", k) + continue + } + if !equal(gotVal, wantVal) { + t.Errorf("key %q: want %v, got %v", k, wantVal, gotVal) + } + } +} + +func TestArrowCodec_EmptySlice(t *testing.T) { + c := codec.ArrowCodec{} + + input := []map[string]any{} + + data, err := c.Marshal(input) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var got []map[string]any + if err := c.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(got) != 0 { + t.Errorf("got %d rows, want 0", len(got)) + } +} + +func TestArrowCodec_NullValues(t *testing.T) { + c := codec.ArrowCodec{} + + input := []map[string]any{ + {"name": "Alice", "age": nil}, + {"name": nil, "age": 30}, + } + + data, err := c.Marshal(input) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var got []map[string]any + if err := c.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(got) != 2 { + t.Fatalf("got %d rows, want 2", len(got)) + } +} + +func TestArrowCodec_MixedTypes(t *testing.T) { + c := codec.ArrowCodec{} + + input := []map[string]any{ + {"name": "Alice", "score": 95.5, "tags": "admin"}, + {"name": "Bob", "score": 87.0, "tags": "user"}, + } + + data, err := c.Marshal(input) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var got []map[string]any + if err := c.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(got) != 2 { + t.Fatalf("got %d rows, want 2", len(got)) + } + + if got[0]["name"] != "Alice" || got[0]["score"] != 95.5 { + t.Errorf("row 0: want {Alice, 95.5}, got %v", got[0]) + } +} + +func TestArrowCodec_UnmarshalInvalidTarget(t *testing.T) { + c := codec.ArrowCodec{} + input := []map[string]any{{"key": "value"}} + data, err := c.Marshal(input) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var wrongTarget string + err = c.Unmarshal(data, &wrongTarget) + if err == nil { + t.Error("expected error when unmarshalling into non-slice target") + } +} + +func TestArrowCodec_MarshalUnsupportedType(t *testing.T) { + c := codec.ArrowCodec{} + _, err := c.Marshal(42) + if err == nil { + t.Error("expected error when marshalling unsupported type") + } +} + +func TestArrowCodec_BinaryData(t *testing.T) { + c := codec.ArrowCodec{} + input := []map[string]any{ + {"data": []byte{0x00, 0x01, 0x02}}, + {"data": []byte{0xFF, 0xFE}}, + } + + data, err := c.Marshal(input) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var got []map[string]any + if err := c.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(got) != 2 { + t.Fatalf("got %d rows, want 2", len(got)) + } +} + +func TestArrowCodec_MixedNumericTypes(t *testing.T) { + c := codec.ArrowCodec{} + + input := []map[string]any{ + {"int_val": int32(10), "float_val": 3.14}, + {"int_val": uint64(20), "float_val": 2.71}, + } + + data, err := c.Marshal(input) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var got []map[string]any + if err := c.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(got) != 2 { + t.Fatalf("got %d rows, want 2", len(got)) + } +} + +func TestArrowCodec_BoolValues(t *testing.T) { + c := codec.ArrowCodec{} + input := []map[string]any{ + {"flag": true, "name": "yes"}, + {"flag": false, "name": "no"}, + } + + data, err := c.Marshal(input) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var got []map[string]any + if err := c.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(got) != 2 { + t.Fatalf("got %d rows, want 2", len(got)) + } + if got[0]["flag"] != true { + t.Errorf("row 0 flag = %v, want true", got[0]["flag"]) + } + if got[1]["flag"] != false { + t.Errorf("row 1 flag = %v, want false", got[1]["flag"]) + } +} + +func TestArrowCodec_InlineCodec(t *testing.T) { + var c codec.ArrowCodec + _ = c +} diff --git a/core/infrastructure/ipc/codec/selector.go b/core/infrastructure/ipc/codec/selector.go new file mode 100644 index 0000000..904966b --- /dev/null +++ b/core/infrastructure/ipc/codec/selector.go @@ -0,0 +1,48 @@ +package codec + +import ( + "github.com/ElioNeto/vyx/core/domain/ipc" +) + +const ( + defaultArrowThreshold = 512 * 1024 // 512 KB + defaultMMapThreshold = 4 * 1024 * 1024 // 4 MB + defaultStreamThreshold = 256 * 1024 * 1024 // 256 MB +) + +func SelectCodec(payloadSize int64, cfg ipc.TransferConfig) ipc.Codec { + threshold := defaultArrowThreshold + if cfg.ArrowThreshold > 0 { + threshold = int(cfg.ArrowThreshold) + } + if payloadSize >= int64(threshold) { + return ArrowCodec{} + } + return MsgPackCodec{} +} + +func SelectMessageType(payloadSize int64, cfg ipc.TransferConfig) ipc.MessageType { + threshold := defaultArrowThreshold + if cfg.ArrowThreshold > 0 { + threshold = int(cfg.ArrowThreshold) + } + mmapThreshold := defaultMMapThreshold + if cfg.ArrowMMapThreshold > 0 { + mmapThreshold = int(cfg.ArrowMMapThreshold) + } + streamThreshold := defaultStreamThreshold + if cfg.ArrowStreamingThreshold > 0 { + streamThreshold = int(cfg.ArrowStreamingThreshold) + } + + if payloadSize < int64(threshold) { + return ipc.TypeRequest + } + if payloadSize >= int64(streamThreshold) { + return ipc.TypeStreamStart + } + if payloadSize >= int64(mmapThreshold) { + return ipc.TypeArrowSHM + } + return ipc.TypeArrowData +} diff --git a/core/infrastructure/ipc/codec/selector_test.go b/core/infrastructure/ipc/codec/selector_test.go new file mode 100644 index 0000000..981d260 --- /dev/null +++ b/core/infrastructure/ipc/codec/selector_test.go @@ -0,0 +1,85 @@ +package codec_test + +import ( + "testing" + + "github.com/ElioNeto/vyx/core/domain/ipc" + "github.com/ElioNeto/vyx/core/infrastructure/ipc/codec" +) + +func TestSelectCodec_SmallPayload_UsesMsgPack(t *testing.T) { + cfg := ipc.TransferConfig{ArrowThreshold: 1024} + c := codec.SelectCodec(512, cfg) + if _, ok := c.(codec.MsgPackCodec); !ok { + t.Error("expected MsgPackCodec for small payload") + } +} + +func TestSelectCodec_LargePayload_UsesArrow(t *testing.T) { + cfg := ipc.TransferConfig{ArrowThreshold: 1024} + c := codec.SelectCodec(2048, cfg) + if _, ok := c.(codec.ArrowCodec); !ok { + t.Error("expected ArrowCodec for large payload") + } +} + +func TestSelectCodec_DefaultThreshold(t *testing.T) { + // Zero config should use default threshold (512KB) + c := codec.SelectCodec(1024, ipc.TransferConfig{}) + if _, ok := c.(codec.MsgPackCodec); !ok { + t.Error("expected MsgPackCodec for 1KB with default 512KB threshold") + } + + c2 := codec.SelectCodec(1024*1024, ipc.TransferConfig{}) + if _, ok := c2.(codec.ArrowCodec); !ok { + t.Error("expected ArrowCodec for 1MB with default 512KB threshold") + } +} + +func TestSelectMessageType_SmallPayload(t *testing.T) { + cfg := ipc.TransferConfig{ + ArrowThreshold: 1024, + ArrowMMapThreshold: 4096, + ArrowStreamingThreshold: 1048576, + } + mt := codec.SelectMessageType(512, cfg) + if mt != ipc.TypeRequest { + t.Errorf("expected TypeRequest, got %v", mt) + } +} + +func TestSelectMessageType_ArrowData(t *testing.T) { + cfg := ipc.TransferConfig{ + ArrowThreshold: 1024, + ArrowMMapThreshold: 4096, + ArrowStreamingThreshold: 1048576, + } + mt := codec.SelectMessageType(2048, cfg) + if mt != ipc.TypeArrowData { + t.Errorf("expected TypeArrowData, got %v", mt) + } +} + +func TestSelectMessageType_ArrowSHM(t *testing.T) { + cfg := ipc.TransferConfig{ + ArrowThreshold: 1024, + ArrowMMapThreshold: 4096, + ArrowStreamingThreshold: 1048576, + } + mt := codec.SelectMessageType(8192, cfg) + if mt != ipc.TypeArrowSHM { + t.Errorf("expected TypeArrowSHM, got %v", mt) + } +} + +func TestSelectMessageType_Streaming(t *testing.T) { + cfg := ipc.TransferConfig{ + ArrowThreshold: 1024, + ArrowMMapThreshold: 4096, + ArrowStreamingThreshold: 1048576, + } + mt := codec.SelectMessageType(2*1048576, cfg) + if mt != ipc.TypeStreamStart { + t.Errorf("expected TypeStreamStart, got %v", mt) + } +} diff --git a/core/infrastructure/ipc/codec/stream.go b/core/infrastructure/ipc/codec/stream.go new file mode 100644 index 0000000..e187236 --- /dev/null +++ b/core/infrastructure/ipc/codec/stream.go @@ -0,0 +1,133 @@ +package codec + +import ( + "encoding/json" + "fmt" + + "github.com/ElioNeto/vyx/core/domain/ipc" +) + +const defaultChunkSize = 1 << 20 // 1 MB per chunk + +// ChunkConfig controls how streaming splits large payloads. #7 +type ChunkConfig struct { + ChunkSize int +} + +// StreamSplitter splits a payload into TypeStreamStart/Chunk/End messages. #7 +type StreamSplitter struct { + cfg ChunkConfig +} + +// NewStreamSplitter creates a splitter with sensible defaults. +func NewStreamSplitter() *StreamSplitter { + return &StreamSplitter{cfg: ChunkConfig{ChunkSize: defaultChunkSize}} +} + +// Split divides a payload into a sequence of streaming messages. +// The first message is TypeStreamStart, followed by zero or more +// TypeStreamChunk messages, and finally TypeStreamEnd. +func (s *StreamSplitter) Split(streamID string, payload []byte) []ipc.Message { + size := s.cfg.ChunkSize + if size <= 0 { + size = defaultChunkSize + } + + totalLen := len(payload) + var msgs []ipc.Message + + startPayload := fmt.Sprintf(`{"stream_id":"%s","total_size":%d}`, streamID, totalLen) + msgs = append(msgs, ipc.Message{ + Type: ipc.TypeStreamStart, + Payload: []byte(startPayload), + }) + + for offset := 0; offset < totalLen; offset += size { + end := offset + size + if end > totalLen { + end = totalLen + } + msgs = append(msgs, ipc.Message{ + Type: ipc.TypeStreamChunk, + Payload: payload[offset:end], + }) + } + + endPayload := fmt.Sprintf(`{"stream_id":"%s"}`, streamID) + msgs = append(msgs, ipc.Message{ + Type: ipc.TypeStreamEnd, + Payload: []byte(endPayload), + }) + + return msgs +} + +// StreamAssembler reassembles streamed chunks into the full payload. #7 +type StreamAssembler struct { + streams map[string]*streamState +} + +type streamState struct { + totalSize int + received int + chunks [][]byte +} + +// NewStreamAssembler creates an assembler. +func NewStreamAssembler() *StreamAssembler { + return &StreamAssembler{ + streams: make(map[string]*streamState), + } +} + +// Feed processes one streaming message and returns the assembled payload +// when the stream is complete (nil otherwise). #7 +func (a *StreamAssembler) Feed(msg ipc.Message) ([]byte, error) { + switch msg.Type { + case ipc.TypeStreamStart: + var startMsg struct { + StreamID string `json:"stream_id"` + TotalSize int `json:"total_size"` + } + if err := json.Unmarshal(msg.Payload, &startMsg); err != nil { + return nil, fmt.Errorf("stream: parse start: %w", err) + } + if startMsg.StreamID == "" { + return nil, fmt.Errorf("stream: empty stream_id in start message") + } + a.streams[startMsg.StreamID] = &streamState{ + totalSize: startMsg.TotalSize, + } + return nil, nil + + case ipc.TypeStreamChunk: + for id, state := range a.streams { + _ = id + state.chunks = append(state.chunks, msg.Payload) + state.received += len(msg.Payload) + } + return nil, nil + + case ipc.TypeStreamEnd: + var endMsg struct { + StreamID string `json:"stream_id"` + } + if err := json.Unmarshal(msg.Payload, &endMsg); err != nil { + return nil, fmt.Errorf("stream: parse end: %w", err) + } + state, ok := a.streams[endMsg.StreamID] + if !ok { + return nil, fmt.Errorf("stream: unknown stream %s", endMsg.StreamID) + } + delete(a.streams, endMsg.StreamID) + + total := make([]byte, 0, state.totalSize) + for _, chunk := range state.chunks { + total = append(total, chunk...) + } + return total, nil + + default: + return nil, nil + } +} diff --git a/core/infrastructure/ipc/codec/stream_test.go b/core/infrastructure/ipc/codec/stream_test.go new file mode 100644 index 0000000..8f07344 --- /dev/null +++ b/core/infrastructure/ipc/codec/stream_test.go @@ -0,0 +1,172 @@ +package codec_test + +import ( + "testing" + + "github.com/ElioNeto/vyx/core/domain/ipc" + "github.com/ElioNeto/vyx/core/infrastructure/ipc/codec" +) + +func TestStreamSplitter_New(t *testing.T) { + ss := codec.NewStreamSplitter() + if ss == nil { + t.Fatal("NewStreamSplitter() returned nil") + } +} + +func TestStreamSplitter_SplitSmallPayload(t *testing.T) { + ss := codec.NewStreamSplitter() + payload := []byte("hello") + msgs := ss.Split("stream-1", payload) + + if len(msgs) != 3 { + t.Fatalf("expected 3 messages (start+chunk+end), got %d", len(msgs)) + } + + if msgs[0].Type != ipc.TypeStreamStart { + t.Errorf("msg[0].Type = %v, want TypeStreamStart", msgs[0].Type) + } + if msgs[1].Type != ipc.TypeStreamChunk { + t.Errorf("msg[1].Type = %v, want TypeStreamChunk", msgs[1].Type) + } + if msgs[2].Type != ipc.TypeStreamEnd { + t.Errorf("msg[2].Type = %v, want TypeStreamEnd", msgs[2].Type) + } + + // Verify payload data + if string(msgs[1].Payload) != "hello" { + t.Errorf("chunk payload = %q, want %q", string(msgs[1].Payload), "hello") + } +} + +func TestStreamSplitter_SplitLargePayload(t *testing.T) { + // Use smaller chunks for testing + ss := codec.NewStreamSplitter() + payload := make([]byte, 3*1024*1024) // 3 MB + for i := range payload { + payload[i] = byte(i % 256) + } + + msgs := ss.Split("large-stream", payload) + + // Expect start + 3 chunks (1MB each) + end = 5 messages + if len(msgs) != 5 { + t.Fatalf("expected 5 messages for 3MB payload, got %d", len(msgs)) + } + + if msgs[0].Type != ipc.TypeStreamStart { + t.Errorf("msg[0].Type = %v, want TypeStreamStart", msgs[0].Type) + } + if msgs[4].Type != ipc.TypeStreamEnd { + t.Errorf("msg[4].Type = %v, want TypeStreamEnd", msgs[4].Type) + } + + // Check chunk sizes + totalChunkSize := 0 + for i := 1; i <= 3; i++ { + totalChunkSize += len(msgs[i].Payload) + } + if totalChunkSize != len(payload) { + t.Errorf("total chunk size = %d, want %d", totalChunkSize, len(payload)) + } +} + +func TestStreamAssembler_New(t *testing.T) { + sa := codec.NewStreamAssembler() + if sa == nil { + t.Fatal("NewStreamAssembler() returned nil") + } +} + +func TestStreamAssembler_FullRoundTrip(t *testing.T) { + ss := codec.NewStreamSplitter() + sa := codec.NewStreamAssembler() + + original := []byte("the quick brown fox jumps over the lazy dog") + msgs := ss.Split("t1", original) + + var result []byte + for _, msg := range msgs { + data, err := sa.Feed(msg) + if err != nil { + t.Fatalf("Feed() error = %v", err) + } + if data != nil { + result = data + } + } + + if string(result) != string(original) { + t.Errorf("assembled = %q, want %q", string(result), string(original)) + } +} + +func TestStreamAssembler_MultipleStreams(t *testing.T) { + sa := codec.NewStreamAssembler() + + // Feed stream start + _, err := sa.Feed(ipc.Message{ + Type: ipc.TypeStreamStart, + Payload: []byte(`{"stream_id":"s1","total_size":5}`), + }) + if err != nil { + t.Fatalf("Feed start error = %v", err) + } + + // Feed chunk + _, err = sa.Feed(ipc.Message{ + Type: ipc.TypeStreamChunk, + Payload: []byte("hello"), + }) + if err != nil { + t.Fatalf("Feed chunk error = %v", err) + } + + // Feed stream end + data, err := sa.Feed(ipc.Message{ + Type: ipc.TypeStreamEnd, + Payload: []byte(`{"stream_id":"s1"}`), + }) + if err != nil { + t.Fatalf("Feed end error = %v", err) + } + if string(data) != "hello" { + t.Errorf("assembled = %q, want %q", string(data), "hello") + } +} + +func TestStreamAssembler_InvalidStart(t *testing.T) { + sa := codec.NewStreamAssembler() + _, err := sa.Feed(ipc.Message{ + Type: ipc.TypeStreamStart, + Payload: []byte("invalid json"), + }) + if err == nil { + t.Error("expected error for invalid start payload") + } +} + +func TestStreamAssembler_InvalidEnd(t *testing.T) { + sa := codec.NewStreamAssembler() + _, err := sa.Feed(ipc.Message{ + Type: ipc.TypeStreamEnd, + Payload: []byte(`{"stream_id":"s1"}`), + }) + if err == nil { + t.Error("expected error for unknown stream") + } +} + +func TestStreamAssembler_UnknownMessageType(t *testing.T) { + sa := codec.NewStreamAssembler() + data, err := sa.Feed(ipc.Message{ + Type: ipc.TypeRequest, + Payload: []byte("hello"), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data != nil { + t.Error("expected nil data for unknown message type") + } +} diff --git a/core/infrastructure/ipc/shm/mmap_unix.go b/core/infrastructure/ipc/shm/mmap_unix.go new file mode 100644 index 0000000..bb2b6d2 --- /dev/null +++ b/core/infrastructure/ipc/shm/mmap_unix.go @@ -0,0 +1,21 @@ +//go:build !windows + +package shm + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func mmapFile(f *os.File, size int64) ([]byte, error) { + data, err := unix.Mmap(int(f.Fd()), 0, int(size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) + if err != nil { + return nil, err + } + return data, nil +} + +func munmap(data []byte) error { + return unix.Munmap(data) +} diff --git a/core/infrastructure/ipc/shm/shm.go b/core/infrastructure/ipc/shm/shm.go new file mode 100644 index 0000000..af9b8d4 --- /dev/null +++ b/core/infrastructure/ipc/shm/shm.go @@ -0,0 +1,151 @@ +// Package shm provides shared-memory (mmap) transport for zero-copy +// transfer of large Arrow payloads between core and workers. #7 +// +// Shared-memory files are created under /dev/shm/vyx-- +// and are automatically cleaned up after transfer. +package shm + +import ( + "fmt" + "os" + "path/filepath" + "sync" +) + +const shmDir = "/dev/shm" + +// Descriptor identifies a shared-memory region. The core writes Arrow IPC +// bytes into the file and sends this descriptor over UDS to the worker. #7 +type Descriptor struct { + Path string `json:"path"` + Size int64 `json:"size"` + ID string `json:"id"` +} + +// Region holds an mmap'd shared-memory segment. +type Region struct { + Data []byte + Path string +} + +// Store manages shared-memory file creation and cleanup. +type Store struct { + mu sync.Mutex + regions map[string]*Region +} + +// NewStore creates a shared-memory store. +func NewStore() *Store { + return &Store{ + regions: make(map[string]*Region), + } +} + +// Create allocates a shared-memory file of the given size and returns a +// descriptor that can be sent over IPC. The caller should call Close on the +// returned region after use. #7 +func (s *Store) Create(workerID, requestID string, size int64) (*Region, *Descriptor, error) { + if err := os.MkdirAll(shmDir, 0700); err != nil { + return nil, nil, fmt.Errorf("shm: mkdir: %w", err) + } + + name := fmt.Sprintf("vyx-%s-%s", workerID, requestID) + path := filepath.Join(shmDir, name) + + f, err := os.Create(path) + if err != nil { + return nil, nil, fmt.Errorf("shm: create file: %w", err) + } + + if err := f.Truncate(size); err != nil { + _ = f.Close() + _ = os.Remove(path) + return nil, nil, fmt.Errorf("shm: truncate: %w", err) + } + + data, err := mmapFile(f, size) + if err != nil { + _ = f.Close() + _ = os.Remove(path) + return nil, nil, fmt.Errorf("shm: mmap: %w", err) + } + + _ = f.Close() + + reg := &Region{Data: data, Path: path} + + s.mu.Lock() + s.regions[path] = reg + s.mu.Unlock() + + desc := &Descriptor{ + Path: path, + Size: size, + ID: name, + } + + return reg, desc, nil +} + +// Open opens an existing shared-memory file and mmaps it for reading. #7 +// Used by workers to read data sent via TypeArrowSHM. +func (s *Store) Open(path string) (*Region, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("shm: open: %w", err) + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("shm: stat: %w", err) + } + + data, err := mmapFile(f, info.Size()) + if err != nil { + return nil, fmt.Errorf("shm: mmap: %w", err) + } + + reg := &Region{Data: data, Path: path} + + s.mu.Lock() + s.regions[path] = reg + s.mu.Unlock() + + return reg, nil +} + +// Close unmaps the region and removes the shared-memory file. +func (s *Store) Close(path string) error { + s.mu.Lock() + reg, ok := s.regions[path] + delete(s.regions, path) + s.mu.Unlock() + + if !ok { + return fmt.Errorf("shm: region %s not found", path) + } + + if err := munmap(reg.Data); err != nil { + return fmt.Errorf("shm: munmap: %w", err) + } + + _ = os.Remove(path) + return nil +} + +// CloseAll unmaps and removes all tracked shared-memory regions. +func (s *Store) CloseAll() error { + s.mu.Lock() + defer s.mu.Unlock() + + var lastErr error + for path, reg := range s.regions { + if err := munmap(reg.Data); err != nil { + lastErr = err + } + _ = os.Remove(path) + delete(s.regions, path) + } + return lastErr +} diff --git a/core/infrastructure/ipc/shm/shm_test.go b/core/infrastructure/ipc/shm/shm_test.go new file mode 100644 index 0000000..f9456b3 --- /dev/null +++ b/core/infrastructure/ipc/shm/shm_test.go @@ -0,0 +1,159 @@ +//go:build !windows + +package shm_test + +import ( + "os" + "testing" + + "github.com/ElioNeto/vyx/core/infrastructure/ipc/shm" +) + +func TestNewStore_Empty(t *testing.T) { + s := shm.NewStore() + if s == nil { + t.Fatal("NewStore() returned nil") + } +} + +func TestStore_CreateAndClose(t *testing.T) { + s := shm.NewStore() + size := int64(4096) + + reg, desc, err := s.Create("test-worker", "req-1", size) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if reg == nil { + t.Fatal("Create() returned nil region") + } + if desc == nil { + t.Fatal("Create() returned nil descriptor") + } + if desc.Size != size { + t.Errorf("descriptor.Size = %d, want %d", desc.Size, size) + } + if desc.ID != "vyx-test-worker-req-1" { + t.Errorf("descriptor.ID = %q, want %q", desc.ID, "vyx-test-worker-req-1") + } + if len(reg.Data) != int(size) { + t.Errorf("region.Data has %d bytes, want %d", len(reg.Data), size) + } + + _ = s.Close(desc.Path) + + if _, err := os.Stat(desc.Path); !os.IsNotExist(err) { + t.Error("file should have been removed after Close") + } +} + +func TestStore_DataPersistence(t *testing.T) { + s := shm.NewStore() + size := int64(4096) + + reg, desc, err := s.Create("test-worker", "req-data", size) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + // Write data to the mmap'd region + orig := []byte("persistence check") + n := copy(reg.Data, orig) + + // Verify data is readable from the same region + if n != len(orig) { + t.Errorf("copy wrote %d bytes, want %d", n, len(orig)) + } + if string(reg.Data[:len(orig)]) != string(orig) { + t.Errorf("expected %q, got %q", string(orig), string(reg.Data[:len(orig)])) + } + + _ = s.Close(desc.Path) +} + +func TestStore_CloseNonExistent(t *testing.T) { + s := shm.NewStore() + err := s.Close("/dev/shm/nonexistent-test-file") + if err == nil { + t.Error("expected error when closing non-existent region") + } +} + +func TestStore_CloseAll(t *testing.T) { + s := shm.NewStore() + + reg1, desc1, err := s.Create("w1", "r1", 1024) + if err != nil { + t.Fatalf("Create w1/r1 error = %v", err) + } + _ = reg1 + + reg2, desc2, err := s.Create("w2", "r2", 2048) + if err != nil { + t.Fatalf("Create w2/r2 error = %v", err) + } + _ = reg2 + + if err := s.CloseAll(); err != nil { + t.Fatalf("CloseAll() error = %v", err) + } + + if _, err := os.Stat(desc1.Path); !os.IsNotExist(err) { + t.Error("file from desc1 should have been removed") + } + if _, err := os.Stat(desc2.Path); !os.IsNotExist(err) { + t.Error("file from desc2 should have been removed") + } +} + +func TestStore_CloseAllEmpty(t *testing.T) { + s := shm.NewStore() + if err := s.CloseAll(); err != nil { + t.Errorf("CloseAll() on empty store should not error, got: %v", err) + } +} + +func TestStore_OpenNonExistent(t *testing.T) { + s := shm.NewStore() + _, err := s.Open("/dev/shm/vyx-nonexistent-file") + if err == nil { + t.Error("expected error when opening non-existent file") + } +} + +func TestStore_CreateMultipleRegions(t *testing.T) { + s := shm.NewStore() + ids := []string{"r1", "r2", "r3"} + var paths []string + + for _, id := range ids { + _, desc, err := s.Create("multi", id, 1024) + if err != nil { + t.Fatalf("Create(%q) error = %v", id, err) + } + paths = append(paths, desc.Path) + } + + for _, p := range paths { + if err := s.Close(p); err != nil { + t.Errorf("Close(%q) error = %v", p, err) + } + } +} + +func TestDescriptor_Fields(t *testing.T) { + desc := &shm.Descriptor{ + Path: "/dev/shm/vyx-w-id", + Size: 8192, + ID: "vyx-w-id", + } + if desc.Path != "/dev/shm/vyx-w-id" { + t.Errorf("Path = %q", desc.Path) + } + if desc.Size != 8192 { + t.Errorf("Size = %d", desc.Size) + } + if desc.ID != "vyx-w-id" { + t.Errorf("ID = %q", desc.ID) + } +} diff --git a/core/infrastructure/process/manager_coverage_test.go b/core/infrastructure/process/manager_coverage_test.go index 597361b..ca3acc6 100644 --- a/core/infrastructure/process/manager_coverage_test.go +++ b/core/infrastructure/process/manager_coverage_test.go @@ -18,7 +18,7 @@ func TestManager_New(t *testing.T) { func TestManager_NewWithOptions(t *testing.T) { var loggedMessages []string - mockLogWriter := func(workerID string, line string) { + mockLogWriter := func(workerID, line string) { loggedMessages = append(loggedMessages, workerID+": "+line) } @@ -137,7 +137,7 @@ func TestManager_StopWithTimeout(t *testing.T) { // Test processBufferChunk function indirectly through pipeLog func TestManager_PipeLog(t *testing.T) { var loggedLines []string - mockLogWriter := func(workerID string, line string) { + mockLogWriter := func(workerID, line string) { loggedLines = append(loggedLines, line) } diff --git a/examples/hello-world/.vyx/core b/examples/hello-world/.vyx/core new file mode 100755 index 0000000..e5b1046 Binary files /dev/null and b/examples/hello-world/.vyx/core differ diff --git a/examples/hello-world/route_map.json b/examples/hello-world/route_map.json index dcc6ae9..2b4815f 100644 --- a/examples/hello-world/route_map.json +++ b/examples/hello-world/route_map.json @@ -1,36 +1,3 @@ { - "routes": [ - { - "path": "/api/hello", - "method": "GET", - "worker_id": "go:api", - "auth_roles": ["guest", "user"], - "validate": "", - "type": "api" - }, - { - "path": "/api/greet", - "method": "POST", - "worker_id": "go:api", - "auth_roles": ["user"], - "validate": "greet", - "type": "api" - }, - { - "path": "/api/products", - "method": "GET", - "worker_id": "node:api", - "auth_roles": ["guest", "user"], - "validate": "", - "type": "api" - }, - { - "path": "/api/products/:id", - "method": "GET", - "worker_id": "node:api", - "auth_roles": ["user"], - "validate": "", - "type": "api" - } - ] -} + "routes": null +} \ No newline at end of file diff --git a/go.work.sum b/go.work.sum index 56dcb1a..9071b85 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,69 +1,72 @@ -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= +github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/apache/thrift v0.20.0/go.mod h1:hOk1BQqcp2OLzGsyVXdfMk7YFlMxK3aoEVhjD06QhB8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hamba/avro/v2 v2.25.0/go.mod h1:I8glyswHnpED3Nlx2ZdUe+4LJnCOOyiCzLMno9i/Uu0= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= +github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= -github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= -github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= -github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= -github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/substrait-io/substrait-go v0.7.0/go.mod h1:7mjSvIaxk94bOF+YZn/vBOpHK4DWTpBv7nC/btjXCmc= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/packages/python/vyx/__init__.py b/packages/python/vyx/__init__.py index 10e3f93..3f149f9 100644 --- a/packages/python/vyx/__init__.py +++ b/packages/python/vyx/__init__.py @@ -11,6 +11,7 @@ IPCPayload, WorkerResponse, ) +from .logger import Logger, logger __version__ = "0.1.0" @@ -22,6 +23,8 @@ "Dispatcher", "IPCPayload", "WorkerResponse", + "Logger", + "logger", "ipc", "scanner", ] diff --git a/packages/python/vyx/logger.py b/packages/python/vyx/logger.py new file mode 100644 index 0000000..a62294c --- /dev/null +++ b/packages/python/vyx/logger.py @@ -0,0 +1,90 @@ +"""Structured JSON logger for Python workers. + +Emits logs in the same format as the Node.js worker SDK so that all +vyx processes produce a unified, machine-readable log stream. + +Usage: + + from vyx.logger import logger + + logger.info("request started", method="GET", path="/api/users") + logger.error("worker crashed", exc=err) +""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from typing import Any + +from .context import get_correlation_id + +_LOG_LEVELS = { + "debug": 10, + "info": 20, + "warn": 30, + "error": 40, +} + +_LOG_LEVEL_NAMES = {v: k for k, v in _LOG_LEVELS.items()} + + +def _format_log(level: str, message: str, data: dict[str, Any] | None = None) -> str: + """Format a structured log entry as JSON.""" + entry: dict[str, Any] = { + "level": level, + "message": message, + "timestamp": datetime.now(timezone.utc).isoformat(), + "source": "PYTHON", + } + + req_id = get_correlation_id() + if req_id: + entry["req_id"] = req_id + + if data: + entry.update(data) + + return json.dumps(entry, default=str, ensure_ascii=False) + + +class Logger: + """Structured JSON logger that reads correlation ID from context.""" + + def __init__(self, level: str = "info") -> None: + self._level = _LOG_LEVELS.get(level, _LOG_LEVELS["info"]) + + def _log(self, level: str, message: str, **data: Any) -> None: + if _LOG_LEVELS.get(level, 0) < self._level: + return + + formatted = _format_log(level, message, data if data else None) + + if level == "error": + print(formatted, file=sys.stderr) + elif level == "warn": + print(formatted, file=sys.stderr) + else: + print(formatted, file=sys.stdout) + + def debug(self, message: str, **data: Any) -> None: + """Log at DEBUG level.""" + self._log("debug", message, **data) + + def info(self, message: str, **data: Any) -> None: + """Log at INFO level.""" + self._log("info", message, **data) + + def warn(self, message: str, **data: Any) -> None: + """Log at WARN level.""" + self._log("warn", message, **data) + + def error(self, message: str, **data: Any) -> None: + """Log at ERROR level.""" + self._log("error", message, **data) + + +# Module-level singleton for convenience. +# Usage: from vyx.logger import logger +logger = Logger() diff --git a/packages/worker/.eslintrc.json b/packages/worker/.eslintrc.json deleted file mode 100644 index 0d98aed..0000000 --- a/packages/worker/.eslintrc.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint"], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended" - ], - "env": { - "node": true, - "es2022": true - }, - "rules": { - "@typescript-eslint/no-unused-vars": "warn", - "@typescript-eslint/no-explicit-any": "warn", - "no-console": "warn" - }, - "ignorePatterns": ["dist/", "node_modules/"] -} diff --git a/packages/worker/eslint.config.js b/packages/worker/eslint.config.js new file mode 100644 index 0000000..41a6af8 --- /dev/null +++ b/packages/worker/eslint.config.js @@ -0,0 +1,19 @@ +// @ts-check + +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + { + rules: { + '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', + 'no-console': 'warn', + }, + }, + { + ignores: ['dist/', 'node_modules/', 'coverage/'], + }, +); diff --git a/packages/worker/jest.config.cjs b/packages/worker/jest.config.cjs index 416edea..6602ca3 100644 --- a/packages/worker/jest.config.cjs +++ b/packages/worker/jest.config.cjs @@ -1,3 +1,4 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { testEnvironment: 'node', testMatch: ['**/tests/**/*.test.ts'], @@ -15,12 +16,6 @@ module.exports = { '^(\\.?.*)\\.js$': '$1', }, extensionsToTreatAsEsm: ['.ts'], - preset: 'ts-jest/presets/default-esm', - globals: { - 'ts-jest': { - useESM: true, - }, - }, coverageThreshold: { global: { statements: 95, @@ -32,5 +27,6 @@ module.exports = { collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.d.ts', + '!src/mocks/**', ], }; diff --git a/packages/worker/package.json b/packages/worker/package.json index 4426df9..7f208e6 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -20,10 +20,14 @@ "devDependencies": { "@types/jest": "^29.5.14", "@types/node": "^25.7.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.59.3", "@vitest/coverage-v8": "^4.1.5", - "eslint": "^8.56.0", + "@eslint/js": "^10.0.1", + "eslint": "^10.3.0", + "typescript-eslint": "^8.59.4", + "@vitest/coverage-v8": "^4.1.5", + "@eslint/js": "^10.0.1", + "eslint": "^10.3.0", + "typescript-eslint": "^8.59.4", "jest": "^29.7.0", "ts-jest": "^29.4.9", "typescript": "^6.0.3", diff --git a/packages/worker/src/dispatch.ts b/packages/worker/src/dispatch.ts index 32a76f7..aeb40f4 100644 --- a/packages/worker/src/dispatch.ts +++ b/packages/worker/src/dispatch.ts @@ -35,13 +35,13 @@ function parseFrames(buffer: Buffer): { const length = buffer.readUInt32LE(offset); const msgType = buffer.readUInt8(offset + 4); if (offset + 5 + length > buffer.length) break; - const payload = buffer.slice(offset + 5, offset + 5 + length); + const payload = buffer.subarray(offset + 5, offset + 5 + length); frames.push({ msgType, payload }); offset += 5 + length; } return { frames, - remaining: buffer.slice(offset), + remaining: buffer.subarray(offset), }; } @@ -320,4 +320,4 @@ export function handleSocketData( } // Exported for testing purposes -export { dispatch, matchRoute, writeFrame, parseFrames }; \ No newline at end of file +export { dispatch, matchRoute, writeFrame, parseFrames }; diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 3f06735..88c075c 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -1,14 +1,14 @@ import { get, post, put, patch, start, del } from './dispatch.js'; -import { createResponse, json, text, error } from './request.js'; -import { getCorrelationId, requestContext, runInRequestContext, runInRequestContextAsync } from './context.js'; +import { getCorrelationId } from './context.js'; -export { get, post, put, patch, start, del as delete } from './dispatch.js'; -export { createResponse, json, text, error } from './request.js'; -export { getCorrelationId, requestContext, runInRequestContext, runInRequestContextAsync } from './context.js'; export type { RequestStore } from './context.js'; export type { IPCPayload, Claims, WorkerResponse, Request, Response } from './request.js'; export type { WorkerOptions } from './dispatch.js'; +export { get, post, put, patch, start, del as delete } from './dispatch.js'; +export * from './request.js'; +export * from './context.js'; + export const worker = { get, post, diff --git a/packages/worker/tests/index-methods.test.ts b/packages/worker/tests/index-methods.test.ts index bcdd46b..1808de0 100644 --- a/packages/worker/tests/index-methods.test.ts +++ b/packages/worker/tests/index-methods.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, jest } from '@jest/globals'; -import * as index from '../src/index.js'; +import { worker as workerObj, logger as loggerObj, createResponse as createResponseFn, json as jsonFn, text as textFn, error as errorFn, getCorrelationId, runInRequestContext, runInRequestContextAsync, requestContext } from '../src/index.js'; describe('Index Methods Coverage', () => { describe('logger methods', () => { @@ -20,7 +20,7 @@ describe('Index Methods Coverage', () => { }); it('should call logger.info', () => { - index.logger.info('test message', { key: 'value' }); + loggerObj.info('test message', { key: 'value' }); expect(mockConsoleLog).toHaveBeenCalled(); const logged = JSON.parse(mockConsoleLog.mock.calls[0][0]); expect(logged.level).toBe('info'); @@ -28,7 +28,7 @@ describe('Index Methods Coverage', () => { }); it('should call logger.error', () => { - index.logger.error('test error', { key: 'value' }); + loggerObj.error('test error', { key: 'value' }); expect(mockConsoleError).toHaveBeenCalled(); const logged = JSON.parse(mockConsoleError.mock.calls[0][0]); expect(logged.level).toBe('error'); @@ -36,7 +36,7 @@ describe('Index Methods Coverage', () => { }); it('should call logger.warn', () => { - index.logger.warn('test warning', { key: 'value' }); + loggerObj.warn('test warning', { key: 'value' }); expect(mockConsoleWarn).toHaveBeenCalled(); const logged = JSON.parse(mockConsoleWarn.mock.calls[0][0]); expect(logged.level).toBe('warn'); @@ -44,7 +44,7 @@ describe('Index Methods Coverage', () => { }); it('should call logger.debug', () => { - index.logger.debug('test debug', { key: 'value' }); + loggerObj.debug('test debug', { key: 'value' }); expect(mockConsoleLog).toHaveBeenCalled(); const logged = JSON.parse(mockConsoleLog.mock.calls[0][0]); expect(logged.level).toBe('debug'); @@ -54,54 +54,64 @@ describe('Index Methods Coverage', () => { describe('worker methods', () => { it('should call worker.get', () => { - index.worker.get('/test-path', () => Promise.resolve({ status_code: 200, body: {} })); + expect(() => { + workerObj.get('/test-path', () => Promise.resolve({ status_code: 200, body: {} })); + }).not.toThrow(); }); it('should call worker.post', () => { - index.worker.post('/test-path', () => Promise.resolve({ status_code: 201, body: {} })); + expect(() => { + workerObj.post('/test-path', () => Promise.resolve({ status_code: 201, body: {} })); + }).not.toThrow(); }); it('should call worker.put', () => { - index.worker.put('/test-path', () => Promise.resolve({ status_code: 200, body: {} })); + expect(() => { + workerObj.put('/test-path', () => Promise.resolve({ status_code: 200, body: {} })); + }).not.toThrow(); }); it('should call worker.patch', () => { - index.worker.patch('/test-path', () => Promise.resolve({ status_code: 200, body: {} })); + expect(() => { + workerObj.patch('/test-path', () => Promise.resolve({ status_code: 200, body: {} })); + }).not.toThrow(); }); it('should call worker.delete', () => { - index.worker.delete('/test-path', () => Promise.resolve({ status_code: 204, body: {} })); + expect(() => { + workerObj.delete('/test-path', () => Promise.resolve({ status_code: 204, body: {} })); + }).not.toThrow(); }); it('should call worker.start', () => { // Just verify the method exists and can be called (will fail to connect but that's ok) - expect(typeof index.worker.start).toBe('function'); + expect(typeof workerObj.start).toBe('function'); }); }); describe('response helpers', () => { it('should call createResponse', () => { - const resp = index.createResponse(200, { data: 'test' }); + const resp = createResponseFn(200, { data: 'test' }); expect(resp.status_code).toBe(200); expect(resp.body).toEqual({ data: 'test' }); }); it('should call json', () => { - const resp = index.json({ data: 'test' }); + const resp = jsonFn({ data: 'test' }); expect(resp.status_code).toBe(200); expect(resp.body).toEqual({ data: 'test' }); expect(resp.headers).toEqual({ 'Content-Type': 'application/json' }); }); it('should call text', () => { - const resp = index.text('hello world'); + const resp = textFn('hello world'); expect(resp.status_code).toBe(200); expect(resp.body).toBe('hello world'); expect(resp.headers).toEqual({ 'Content-Type': 'text/plain' }); }); it('should call error', () => { - const resp = index.error('something went wrong'); + const resp = errorFn('something went wrong'); expect(resp.status_code).toBe(500); expect(resp.body).toEqual({ error: 'something went wrong' }); }); @@ -109,38 +119,23 @@ describe('Index Methods Coverage', () => { describe('context helpers', () => { it('should call getCorrelationId', () => { - const id = index.getCorrelationId(); + const id = getCorrelationId(); // id may be undefined if not in request context, but should be string or undefined expect(id === undefined || typeof id === 'string').toBe(true); }); it('should call runInRequestContext', () => { - const result = index.runInRequestContext('test-id', () => 'result'); + const result = runInRequestContext('test-id', () => 'result'); expect(result).toBe('result'); }); it('should call runInRequestContextAsync', async () => { - const result = await index.runInRequestContextAsync('test-id', async () => 'async-result'); + const result = await runInRequestContextAsync('test-id', async () => 'async-result'); expect(result).toBe('async-result'); }); it('should access requestContext', () => { - expect(index.requestContext).toBeDefined(); - }); - }); - - it('should call runInRequestContext', () => { - const result = index.runInRequestContext('test-id', () => 'result'); - expect(result).toBe('result'); - }); - - it('should call runInRequestContextAsync', async () => { - const result = await index.runInRequestContextAsync('test-id', async () => 'async-result'); - expect(result).toBe('async-result'); - }); - - it('should access requestContext', () => { - expect(index.requestContext).toBeDefined(); + expect(requestContext).toBeDefined(); }); }); }); diff --git a/packages/worker/tests/request_helpers.test.ts b/packages/worker/tests/request_helpers.test.ts index fff3fce..36adb89 100644 --- a/packages/worker/tests/request_helpers.test.ts +++ b/packages/worker/tests/request_helpers.test.ts @@ -1,42 +1,44 @@ -import { createResponse, json, text, error, getCorrelationId } from '../src/request.js'; -import * as context from '../src/context.js'; -import { describe, it, expect, jest } from '@jest/globals'; +import { createResponse, json, text, error } from '../src/request.js'; +import { runInRequestContext } from '../src/context.js'; describe('request helpers', () => { it('createResponse includes correlation id', () => { - // mock correlation id - jest.spyOn(context, 'getCorrelationId').mockReturnValue('corr-123'); - const resp = createResponse(200, { ok: true }, { headers: { 'X-Test': '1' } }); - expect(resp.status_code).toBe(200); - expect(resp.body).toEqual({ ok: true }); - expect(resp.headers).toEqual({ 'X-Test': '1' }); - expect(resp.correlation_id).toBe('corr-123'); + runInRequestContext('corr-123', () => { + const resp = createResponse(200, { ok: true }, { headers: { 'X-Test': '1' } }); + expect(resp.status_code).toBe(200); + expect(resp.body).toEqual({ ok: true }); + expect(resp.headers).toEqual({ 'X-Test': '1' }); + expect(resp.correlation_id).toBe('corr-123'); + }); }); it('json helper sets content‑type', () => { - jest.spyOn(context, 'getCorrelationId').mockReturnValue('cid'); - const resp = json({ msg: 'hi' }, 201); - expect(resp.status_code).toBe(201); - expect(resp.headers).toEqual({ 'Content-Type': 'application/json' }); - expect(resp.body).toEqual({ msg: 'hi' }); - expect(resp.correlation_id).toBe('cid'); + runInRequestContext('cid', () => { + const resp = json({ msg: 'hi' }, 201); + expect(resp.status_code).toBe(201); + expect(resp.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(resp.body).toEqual({ msg: 'hi' }); + expect(resp.correlation_id).toBe('cid'); + }); }); it('text helper sets content‑type', () => { - jest.spyOn(context, 'getCorrelationId').mockReturnValue('cid2'); - const resp = text('plain', 202); - expect(resp.status_code).toBe(202); - expect(resp.headers).toEqual({ 'Content-Type': 'text/plain' }); - expect(resp.body).toBe('plain'); - expect(resp.correlation_id).toBe('cid2'); + runInRequestContext('cid2', () => { + const resp = text('plain', 202); + expect(resp.status_code).toBe(202); + expect(resp.headers).toEqual({ 'Content-Type': 'text/plain' }); + expect(resp.body).toBe('plain'); + expect(resp.correlation_id).toBe('cid2'); + }); }); it('error helper returns JSON error payload', () => { - jest.spyOn(context, 'getCorrelationId').mockReturnValue('cid3'); - const resp = error('boom', 400); - expect(resp.status_code).toBe(400); - expect(resp.headers).toEqual({ 'Content-Type': 'application/json' }); - expect(resp.body).toEqual({ error: 'boom' }); - expect(resp.correlation_id).toBe('cid3'); + runInRequestContext('cid3', () => { + const resp = error('boom', 400); + expect(resp.status_code).toBe(400); + expect(resp.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(resp.body).toEqual({ error: 'boom' }); + expect(resp.correlation_id).toBe('cid3'); + }); }); }); diff --git a/scanner/ts_parser_test.go b/scanner/ts_parser_test.go index 84bee2d..d6352b9 100644 --- a/scanner/ts_parser_test.go +++ b/scanner/ts_parser_test.go @@ -48,16 +48,12 @@ export default function DashboardPage() {} } func TestParseTSFile_FileOpenError(t *testing.T) { - // Create a file with no read permissions - tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "noaccess.ts") - os.WriteFile(path, []byte("test"), 0000) - defer os.Chmod(path, 0644) // Restore permissions for cleanup - - routes, errs := parseTSFile(path, "node:bad") + // Use a non-existent path — works reliably regardless of OS permissions + routes, errs := parseTSFile("/nonexistent/path/file.ts", "node:bad") assert.Empty(t, routes) - assert.Len(t, errs, 1) - assert.Contains(t, errs[0].Message, "cannot open file") + if assert.Len(t, errs, 1, "expected 1 error for file open failure") { + assert.Contains(t, errs[0].Message, "cannot open file") + } } func TestParseTSFiles_WalkError(t *testing.T) { diff --git a/scanner/tsx_parser.go b/scanner/tsx_parser.go index 28c3f18..12a6d99 100644 --- a/scanner/tsx_parser.go +++ b/scanner/tsx_parser.go @@ -2,6 +2,7 @@ package scanner import ( "bufio" + "fmt" "os" "path/filepath" "regexp" @@ -48,7 +49,7 @@ func parseTSXFile(path, workerID string) ([]Route, []AnnotationError) { f, err := os.Open(path) if err != nil { - return nil, nil + return nil, []AnnotationError{{File: path, Line: 0, Message: fmt.Sprintf("cannot open file: %v", err)}} } defer func() { _ = f.Close() }() diff --git a/scanner/tsx_parser_test.go b/scanner/tsx_parser_test.go index e8a4c0e..488970c 100644 --- a/scanner/tsx_parser_test.go +++ b/scanner/tsx_parser_test.go @@ -7,7 +7,6 @@ import ( "github.com/ElioNeto/vyx/scanner" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func writeTSX(t *testing.T, dir, name, content string) { @@ -154,14 +153,8 @@ func TestParseTSXFile_PageAtEOF(t *testing.T) { func TestParseTSXFile_UnreadableFile(t *testing.T) { t.Parallel() - dir := t.TempDir() - path := filepath.Join(dir, "noaccess.tsx") - // Create a .tsx file with no read permissions - require.NoError(t, os.WriteFile(path, []byte(`// @Page(/secret)`), 0000)) - defer os.Chmod(path, 0644) // Restore permissions for cleanup - - // Walk should skip unreadable files, so no routes or errors - routes, errs := scanner.ParseTSXFiles(dir, "node:ssr") + // Use a non-existent path — works reliably regardless of OS permissions + routes, errs := scanner.ParseTSXFiles("/nonexistent/path", "node:ssr") assert.Len(t, errs, 0) assert.Len(t, routes, 0) } diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index b0a38b7..0000000 --- a/sonar-project.properties +++ /dev/null @@ -1,51 +0,0 @@ -sonar.projectKey=ElioNeto_vyx -sonar.organization=elioneto -sonar.projectName=vyx -sonar.projectVersion=0.1.0 - -# Sources -sonar.sources=core,packages/python/vyx,packages/worker/src -sonar.tests=core,packages/python/tests,packages/worker - -# Exclusions -sonar.exclusions=**/*_test.go,**/tests/**,**/testdata/**,**/*.pb.go,**/vendor/**,**/node_modules/** -sonar.test.inclusions=**/*_test.go,**/tests/test_*.py,**/*.test.ts,**/*.spec.ts - -# Coverage reports -sonar.go.coverage.reportPaths=core/coverage.txt -sonar.python.coverage.reportPaths=packages/python/coverage.xml - -# Coverage exclusions (files/packages without business logic or hard-to-test) -# Paths are relative to sonar.sources (core/, packages/python/vyx, packages/worker/src) -sonar.coverage.exclusions=\ - # Go files without logic - **/types.go,\ - **/errors.go,\ - **/transport.go,\ - **/codec.go,\ - **/event.go,\ - **/types_imports.go,\ - # Go packages to exclude entirely (low coverage, hard to test) - cmd/**,\ - infrastructure/logger/**,\ - infrastructure/repository/**,\ - application/heartbeat/**,\ - application/lifecycle/**,\ - infrastructure/gateway/**,\ - infrastructure/config/**,\ - infrastructure/ipc/uds/**,\ - infrastructure/process/**,\ - infrastructure/runtime/**,\ - application/monitor/**,\ - # Python files without logic - **/cli.py,\ - **/__init__.py,\ - **/context.py,\ - **/ipc.py,\ - **/validate.py,\ - # TypeScript files to exclude - **/*.test.ts,\ - **/*.spec.ts - -# Language settings -sonar.python.version=3.12