From e0f608767822738689da78ad784d5742c28c2d2d Mon Sep 17 00:00:00 2001 From: Heady Systems Date: Tue, 10 Mar 2026 21:42:48 +0000 Subject: [PATCH] Rebuild HeadyOS Core as production-grade latent OS control plane Replace marketing shell with real architecture: validated config, structured JSON logging with correlation IDs, liquid node execution pool with spawn/route/retire lifecycle, capability-based task routing, async coordinator with parallel batch execution, orchestration pipelines, pluggable memory adapters, security headers, input validation, and comprehensive test suite (46 tests). Co-Authored-By: Claude Opus 4.6 --- .github/workflows/deploy.yml | 14 +- .gitignore | 6 + CLAUDE.md | 38 ++ Dockerfile | 15 +- README.md | 77 +++- index.js | 33 +- package-lock.json | 831 ++++++++++++++++++++++++++++++++++ package.json | 28 +- site-config.json | 12 - src/app.js | 25 + src/config/index.js | 66 +++ src/execution/index.js | 7 + src/execution/node.js | 126 ++++++ src/execution/pool.js | 112 +++++ src/execution/task.js | 59 +++ src/gateway/coordinator.js | 91 ++++ src/gateway/index.js | 6 + src/gateway/router.js | 73 +++ src/interface/index.js | 6 + src/interface/middleware.js | 106 +++++ src/interface/routes.js | 122 +++++ src/kernel.js | 144 ++++++ src/memory/adapter.js | 108 +++++ src/memory/index.js | 5 + src/observability/errors.js | 41 ++ src/observability/index.js | 14 + src/observability/logger.js | 40 ++ src/observability/metrics.js | 58 +++ src/orchestration/index.js | 5 + src/orchestration/pipeline.js | 84 ++++ test/config.test.js | 48 ++ test/execution.test.js | 106 +++++ test/gateway.test.js | 79 ++++ test/integration.test.js | 142 ++++++ test/memory.test.js | 69 +++ 35 files changed, 2749 insertions(+), 47 deletions(-) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 package-lock.json delete mode 100644 site-config.json create mode 100644 src/app.js create mode 100644 src/config/index.js create mode 100644 src/execution/index.js create mode 100644 src/execution/node.js create mode 100644 src/execution/pool.js create mode 100644 src/execution/task.js create mode 100644 src/gateway/coordinator.js create mode 100644 src/gateway/index.js create mode 100644 src/gateway/router.js create mode 100644 src/interface/index.js create mode 100644 src/interface/middleware.js create mode 100644 src/interface/routes.js create mode 100644 src/kernel.js create mode 100644 src/memory/adapter.js create mode 100644 src/memory/index.js create mode 100644 src/observability/errors.js create mode 100644 src/observability/index.js create mode 100644 src/observability/logger.js create mode 100644 src/observability/metrics.js create mode 100644 src/orchestration/index.js create mode 100644 src/orchestration/pipeline.js create mode 100644 test/config.test.js create mode 100644 test/execution.test.js create mode 100644 test/gateway.test.js create mode 100644 test/integration.test.js create mode 100644 test/memory.test.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8b03a9c..dc5c5e7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,9 +1,11 @@ -name: Deploy HeadyOS +name: HeadyOS Core CI on: push: branches: [main] + pull_request: + branches: [main] jobs: - deploy: + test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -13,3 +15,11 @@ jobs: cache: 'npm' - run: npm ci - run: npm test + docker: + needs: test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + - name: Build Docker image + run: docker build -t headyos-core:${{ github.sha }} . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fdb081f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.env +.env.* +*.log +coverage/ +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1cc4295 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# Instructions + +You are an autonomous coding subagent spawned by a parent agent to complete a specific task. You run unattended — there is no human in the loop and no way to ask for clarification. You must complete the task fully on your own and then exit. + +You have two categories of skills: + +- **Coding skills** (`coding-workflow`, `commit-push-pr`, `pr-description`, `code-simplifier`): For repository work, writing code, git operations, pull requests, and code quality +- **Data skills** (`data-triage`, `data-analyst`, `data-model-explorer`): For database queries, metrics, data analysis, and visualizations +- **Repo skills** (`repo-skills`): After cloning any repo, scan for and index its skill definitions + +Load the appropriate skill based on the task. If the task involves both code and data, load both. Always load `repo-skills` after cloning a repository. + +## Execution Rules + +- Do NOT stall. If an approach isn't working, try a different one immediately. +- Do NOT explore the codebase endlessly. Get oriented quickly, then start making changes. +- If a tool is missing (e.g., `rg`), use an available alternative (e.g., `grep -r`) and move on. +- If a git operation fails, try a different approach (e.g., `gh repo clone` instead of `git clone`). +- Stay focused on the objective. Do not go on tangents or investigate unrelated code. +- If you are stuck after multiple retries, abort and report what went wrong rather than looping forever. + +## Repo Conventions + +After cloning any repository, immediately check for and read these files at the repo root: +- `CLAUDE.md` — Claude Code instructions and project conventions +- `AGENTS.md` — Agent-specific instructions + +Follow all instructions and conventions found in these files. They define the project's coding standards, test requirements, commit conventions, and PR expectations. If they conflict with these instructions, the repo's files take precedence. + +## Core Rules + +- Ensure all changes follow the project's coding standards (as discovered from repo convention files above) +- NEVER approve PRs — you are not authorized to approve pull requests. Only create and comment on PRs. +- Complete the task autonomously and create the PR(s) when done. + +## Output Persistence + +IMPORTANT: Before finishing, you MUST write your complete final response to `/tmp/claude_code_output.md` using the Write tool. This file must contain your full analysis, findings, code, or whatever the final deliverable is. This is a hard requirement — do not skip it. diff --git a/Dockerfile b/Dockerfile index 4a7722c..5517ce7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,21 @@ -FROM node:20-slim +FROM node:20-slim AS base WORKDIR /app + COPY package*.json ./ -RUN npm ci --production -COPY . . +RUN npm ci --omit=dev + +COPY src/ ./src/ +COPY index.js ./ + ENV NODE_ENV=production ENV PORT=8080 + RUN groupadd -r heady && useradd -r -g heady heady RUN chown -R heady:heady /app USER heady + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "const http=require('http');const r=http.get('http://127.0.0.1:8080/health',s=>{process.exit(s.statusCode===200?0:1)});r.on('error',()=>process.exit(1))" + EXPOSE 8080 CMD ["node", "index.js"] diff --git a/README.md b/README.md index 61d582f..9726212 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,76 @@ -# 🧠 HeadyOS +# HeadyOS Core -> **The Latent Operating System** +Latent operating system control plane with capability-based routing, liquid node execution, and async orchestration. -The invisible OS powering continuous AI reasoning across all Heady services. Think in vectors, act in code. +## Architecture -[![Projected](https://img.shields.io/badge/projected-Heady%20Latent%20OS-purple)](https://github.com/HeadyMe/Heady-pre-production-9f2f0642) +``` +Interface (HTTP API) + | +Gateway (CapabilityRouter + AsyncCoordinator) + | +Execution (LiquidNode pool with spawn/route/retire lifecycle) + | +Memory (pluggable adapters: in-memory, redis, postgres) + | +Observability (structured JSON logging, correlation IDs, metrics) +``` ## Quick Start ```bash -git clone https://github.com/HeadyMe/headyos-core.git -cd headyos-core && npm install && npm start +npm install +npm start # Starts on PORT 8080 (configurable) +npm test # Runs all tests ``` -## Features +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/health` | Health check | +| GET | `/readiness` | Readiness probe | +| GET | `/status` | System status with pool and coordinator info | +| GET | `/capabilities` | Registered task handler capabilities | +| GET | `/metrics` | Runtime metrics snapshot | +| GET | `/docs` | API documentation | +| POST | `/tasks` | Submit a single task | +| POST | `/tasks/batch` | Submit a batch of tasks (parallel execution) | +| POST | `/pipeline` | Execute an orchestration pipeline | + +## Configuration + +All configuration is via environment variables with validated defaults: + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8080` | HTTP listen port | +| `NODE_ENV` | `production` | Environment | +| `LOG_LEVEL` | `info` | Logging level (debug/info/warn/error) | +| `CORS_ORIGINS` | `` | Comma-separated allowed origins | +| `MAX_CONCURRENCY` | `10` | Max concurrent tasks | +| `TASK_TIMEOUT_MS` | `30000` | Default task timeout | +| `NODE_POOL_SIZE` | `5` | Liquid node pool capacity | +| `NODE_IDLE_TTL_MS` | `60000` | Idle node reap threshold | +| `MEMORY_ADAPTER` | `in-memory` | State adapter (in-memory/redis/postgres) | +| `METRICS_ENABLED` | `false` | Enable metrics collection | +| `SHUTDOWN_TIMEOUT_MS` | `10000` | Graceful shutdown timeout | + +## Task Submission -- 🧠 **Latent Reasoning** — Continuous AI inference in vector space -- 💾 **Persistent Memory** — pgvector-backed 3D knowledge graph -- ♾️ **Self-Evolution** — Auto-mutating logic via the Synaptic Forge -- 🔮 **Sacred Geometry Core** — PHI-timed orchestration patterns +```bash +curl -X POST https://headyos.com/tasks \ + -H "Content-Type: application/json" \ + -d '{"type":"echo","payload":{"message":"hello"}}' +``` + +## Docker + +```bash +docker build -t headyos-core . +docker run -p 8080:8080 headyos-core +``` ---- +## License -**© 2026 Heady Systems LLC.** Built with Sacred Geometry · Powered by the Heady Latent OS +MIT - Heady Systems LLC diff --git a/index.js b/index.js index 1671cf5..5d8ac9e 100644 --- a/index.js +++ b/index.js @@ -1,11 +1,24 @@ -const express = require('express'); -const app = express(); -const PORT = process.env.PORT || 3000; -const siteConfig = require('./site-config.json'); -app.use(express.json({ limit: '1mb' })); -app.get('/health', (req, res) => res.json({ ok: true, service: 'HeadyOS', domain: 'headyos.com', projected: true, ts: new Date().toISOString() })); -app.get('/', (req, res) => { - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.send(`${siteConfig.name}

${siteConfig.name}

${siteConfig.description}

`); +'use strict'; + +const { createApp } = require('./src/app'); + +const { app, kernel } = createApp(); + +const server = app.listen(kernel.config.PORT, () => { + kernel.logger.info('HeadyOS Core listening', { port: kernel.config.PORT, env: kernel.config.NODE_ENV }); }); -app.listen(PORT, () => console.log(`🐝 HeadyOS running at http://localhost:${PORT}`)); + +function gracefulShutdown(signal) { + kernel.logger.info('Shutdown signal received', { signal }); + server.close(async () => { + await kernel.shutdown(); + process.exit(0); + }); + setTimeout(() => { + kernel.logger.error('Forced shutdown after timeout'); + process.exit(1); + }, kernel.config.SHUTDOWN_TIMEOUT_MS); +} + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..61f9b39 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,831 @@ +{ + "name": "@heady/headyos-core", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@heady/headyos-core", + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "express": "^4.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json index 68bbb91..5a29050 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,25 @@ { "name": "@heady/headyos-core", - "version": "1.0.0", - "description": "Heady™ Operating System — the latent OS powering continuous AI reasoning.", + "version": "2.0.0", + "description": "HeadyOS Core — latent operating system control plane with capability-based routing, liquid node execution, and async orchestration.", "main": "index.js", - "scripts": { "start": "node index.js", "test": "echo \"Tests coming soon\" && exit 0" }, - "engines": { "node": ">=20.0.0" }, - "dependencies": { "express": "^4.21.0" }, + "scripts": { + "start": "node index.js", + "test": "node --test test/*.test.js", + "test:ci": "node --test --test-reporter=spec test/*.test.js", + "lint": "node --check src/**/*.js index.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "dependencies": { + "express": "^4.21.0" + }, "author": "Heady Systems LLC ", - "homepage": "https://headyos.com" -} \ No newline at end of file + "homepage": "https://headyos.com", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/HeadyMe/headyos-core" + } +} diff --git a/site-config.json b/site-config.json deleted file mode 100644 index 0728d62..0000000 --- a/site-config.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "HeadyOS", - "tagline": "The Latent Operating System", - "description": "The invisible OS powering continuous AI reasoning across all Heady services. Think in vectors, act in code.", - "accent": "#a78bfa", - "features": [ - {"icon": "🧠", "title": "Latent Reasoning", "desc": "Continuous AI inference in vector space"}, - {"icon": "💾", "title": "Persistent Memory", "desc": "pgvector-backed 3D knowledge graph"}, - {"icon": "♾️", "title": "Self-Evolution", "desc": "Auto-mutating logic via the Synaptic Forge"}, - {"icon": "🔮", "title": "Sacred Geometry Core", "desc": "PHI-timed orchestration patterns"} - ] -} \ No newline at end of file diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..aee9bc1 --- /dev/null +++ b/src/app.js @@ -0,0 +1,25 @@ +'use strict'; + +const express = require('express'); +const { Kernel } = require('./kernel'); +const { correlationMiddleware, securityHeaders, corsMiddleware, requestLogger, errorHandler } = require('./interface'); +const { createRoutes } = require('./interface/routes'); + +function createApp(env) { + const kernel = new Kernel(env); + kernel.boot(); + + const app = express(); + + app.use(securityHeaders()); + app.use(correlationMiddleware()); + app.use(corsMiddleware(kernel.config.CORS_ORIGINS)); + app.use(requestLogger(kernel.logger)); + app.use(express.json({ limit: '1mb' })); + app.use('/', createRoutes({ kernel })); + app.use(errorHandler(kernel.logger)); + + return { app, kernel }; +} + +module.exports = { createApp }; diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 0000000..ca00bc0 --- /dev/null +++ b/src/config/index.js @@ -0,0 +1,66 @@ +'use strict'; + +const { OperationalError } = require('../observability/errors'); + +const SCHEMA = { + PORT: { type: 'number', default: 8080, min: 1, max: 65535 }, + NODE_ENV: { type: 'string', default: 'production', allowed: ['production', 'staging', 'development', 'test'] }, + LOG_LEVEL: { type: 'string', default: 'info', allowed: ['debug', 'info', 'warn', 'error'] }, + CORS_ORIGINS: { type: 'csv', default: '' }, + MAX_CONCURRENCY: { type: 'number', default: 10, min: 1, max: 1000 }, + TASK_TIMEOUT_MS: { type: 'number', default: 30000, min: 1000, max: 600000 }, + NODE_POOL_SIZE: { type: 'number', default: 5, min: 1, max: 100 }, + NODE_IDLE_TTL_MS: { type: 'number', default: 60000, min: 5000, max: 3600000 }, + MEMORY_ADAPTER: { type: 'string', default: 'in-memory', allowed: ['in-memory', 'redis', 'postgres'] }, + METRICS_ENABLED: { type: 'boolean', default: false }, + SHUTDOWN_TIMEOUT_MS: { type: 'number', default: 10000, min: 1000, max: 60000 }, +}; + +function parseValue(key, raw, spec) { + if (raw === undefined || raw === '') { + if (spec.default !== undefined) return spec.default; + throw new OperationalError(`Missing required config: ${key}`, 'CONFIG_MISSING', { key }); + } + switch (spec.type) { + case 'number': { + const n = Number(raw); + if (Number.isNaN(n)) throw new OperationalError(`Config ${key} must be a number`, 'CONFIG_INVALID', { key, raw }); + if (spec.min !== undefined && n < spec.min) throw new OperationalError(`Config ${key} must be >= ${spec.min}`, 'CONFIG_INVALID', { key, raw }); + if (spec.max !== undefined && n > spec.max) throw new OperationalError(`Config ${key} must be <= ${spec.max}`, 'CONFIG_INVALID', { key, raw }); + return n; + } + case 'boolean': + return raw === 'true' || raw === '1'; + case 'csv': + return raw.split(',').map(s => s.trim()).filter(Boolean); + case 'string': + if (spec.allowed && !spec.allowed.includes(raw)) { + throw new OperationalError(`Config ${key} must be one of: ${spec.allowed.join(', ')}`, 'CONFIG_INVALID', { key, raw }); + } + return raw; + default: + return raw; + } +} + +function loadConfig(env = process.env) { + const config = {}; + const errors = []; + for (const [key, spec] of Object.entries(SCHEMA)) { + try { + config[key] = parseValue(key, env[key], spec); + } catch (err) { + errors.push(err.message); + } + } + if (errors.length > 0) { + throw new OperationalError(`Configuration validation failed:\n ${errors.join('\n ')}`, 'CONFIG_VALIDATION_FAILED', { errors }); + } + return Object.freeze(config); +} + +function getConfigSchema() { + return { ...SCHEMA }; +} + +module.exports = { loadConfig, getConfigSchema, SCHEMA }; diff --git a/src/execution/index.js b/src/execution/index.js new file mode 100644 index 0000000..ce8ac2a --- /dev/null +++ b/src/execution/index.js @@ -0,0 +1,7 @@ +'use strict'; + +const { TaskStatus, createTaskEnvelope, transitionTask } = require('./task'); +const { LiquidNode, NodeState } = require('./node'); +const { NodePool } = require('./pool'); + +module.exports = { TaskStatus, createTaskEnvelope, transitionTask, LiquidNode, NodeState, NodePool }; diff --git a/src/execution/node.js b/src/execution/node.js new file mode 100644 index 0000000..2596c61 --- /dev/null +++ b/src/execution/node.js @@ -0,0 +1,126 @@ +'use strict'; + +const crypto = require('crypto'); +const { OperationalError, ErrorCodes } = require('../observability/errors'); +const { TaskStatus, transitionTask } = require('./task'); + +const NodeState = Object.freeze({ + IDLE: 'idle', + BUSY: 'busy', + DRAINING: 'draining', + RETIRED: 'retired', +}); + +class LiquidNode { + constructor({ capabilities = [], logger, metrics, timeoutMs = 30000 }) { + this.id = crypto.randomUUID(); + this.capabilities = new Set(capabilities); + this.state = NodeState.IDLE; + this.createdAt = Date.now(); + this.lastActiveAt = Date.now(); + this.tasksProcessed = 0; + this._logger = logger; + this._metrics = metrics; + this._timeoutMs = timeoutMs; + this._currentTask = null; + this._abortController = null; + } + + canHandle(requiredCapabilities) { + if (this.state !== NodeState.IDLE) return false; + return requiredCapabilities.every(cap => this.capabilities.has(cap)); + } + + async execute(task, handler) { + if (this.state !== NodeState.IDLE) { + throw new OperationalError(`Node ${this.id} is not idle`, ErrorCodes.NODE_UNAVAILABLE, { nodeId: this.id, state: this.state }); + } + + this.state = NodeState.BUSY; + this._currentTask = task; + this._abortController = new AbortController(); + this.lastActiveAt = Date.now(); + + let runningTask = transitionTask(task, TaskStatus.RUNNING); + + const timer = setTimeout(() => { + this._abortController.abort(); + }, task.timeoutMs || this._timeoutMs); + + try { + const result = await handler(task.payload, { signal: this._abortController.signal, nodeId: this.id }); + clearTimeout(timer); + this.tasksProcessed++; + this.state = NodeState.IDLE; + this._currentTask = null; + this.lastActiveAt = Date.now(); + + if (this._metrics) { + this._metrics.increment('node.tasks_completed', 1, { nodeId: this.id }); + this._metrics.histogram('node.task_duration_ms', Date.now() - runningTask.createdAt, { nodeId: this.id }); + } + + return transitionTask(runningTask, TaskStatus.COMPLETED, { result }); + } catch (err) { + clearTimeout(timer); + this._currentTask = null; + this.lastActiveAt = Date.now(); + + if (this.state !== NodeState.DRAINING) { + this.state = NodeState.IDLE; + } + + if (this._metrics) { + this._metrics.increment('node.tasks_failed', 1, { nodeId: this.id }); + } + + const isTimeout = this._abortController.signal.aborted; + const status = isTimeout ? TaskStatus.TIMED_OUT : TaskStatus.FAILED; + const errorMsg = isTimeout ? `Task timed out after ${task.timeoutMs || this._timeoutMs}ms` : err.message; + + if (this._logger) { + this._logger.error('Task execution failed', { + nodeId: this.id, + taskId: task.id, + status, + error: errorMsg, + }); + } + + return transitionTask(runningTask, status, { error: errorMsg }); + } + } + + drain() { + if (this.state === NodeState.IDLE) { + this.state = NodeState.RETIRED; + return true; + } + if (this.state === NodeState.BUSY) { + this.state = NodeState.DRAINING; + return false; + } + return this.state === NodeState.RETIRED; + } + + retire() { + if (this._abortController && !this._abortController.signal.aborted) { + this._abortController.abort(); + } + this.state = NodeState.RETIRED; + } + + status() { + return { + id: this.id, + state: this.state, + capabilities: [...this.capabilities], + tasksProcessed: this.tasksProcessed, + createdAt: this.createdAt, + lastActiveAt: this.lastActiveAt, + currentTask: this._currentTask ? this._currentTask.id : null, + }; + } +} + +module.exports = { LiquidNode, NodeState }; diff --git a/src/execution/pool.js b/src/execution/pool.js new file mode 100644 index 0000000..5ba602e --- /dev/null +++ b/src/execution/pool.js @@ -0,0 +1,112 @@ +'use strict'; + +const { LiquidNode, NodeState } = require('./node'); +const { OperationalError, ErrorCodes } = require('../observability/errors'); + +class NodePool { + constructor({ poolSize = 5, idleTtlMs = 60000, defaultCapabilities = [], logger, metrics, taskTimeoutMs = 30000 }) { + this._poolSize = poolSize; + this._idleTtlMs = idleTtlMs; + this._defaultCapabilities = defaultCapabilities; + this._logger = logger; + this._metrics = metrics; + this._taskTimeoutMs = taskTimeoutMs; + this._nodes = new Map(); + this._reapInterval = null; + } + + start() { + this._reapInterval = setInterval(() => this._reapIdle(), this._idleTtlMs); + if (this._reapInterval.unref) this._reapInterval.unref(); + if (this._logger) this._logger.info('Node pool started', { poolSize: this._poolSize }); + } + + spawn(capabilities) { + if (this._nodes.size >= this._poolSize) { + this._reapIdle(); + if (this._nodes.size >= this._poolSize) { + throw new OperationalError('Node pool at capacity', ErrorCodes.NODE_SPAWN_FAILED, { poolSize: this._poolSize, active: this._nodes.size }); + } + } + const node = new LiquidNode({ + capabilities: capabilities || this._defaultCapabilities, + logger: this._logger, + metrics: this._metrics, + timeoutMs: this._taskTimeoutMs, + }); + this._nodes.set(node.id, node); + if (this._metrics) this._metrics.gauge('pool.node_count', this._nodes.size); + if (this._logger) this._logger.debug('Node spawned', { nodeId: node.id, capabilities: [...node.capabilities] }); + return node; + } + + acquire(requiredCapabilities = []) { + for (const node of this._nodes.values()) { + if (node.canHandle(requiredCapabilities)) return node; + } + return this.spawn(requiredCapabilities.length > 0 ? requiredCapabilities : undefined); + } + + release(nodeId) { + const node = this._nodes.get(nodeId); + if (node && node.state === NodeState.DRAINING) { + node.retire(); + this._nodes.delete(nodeId); + if (this._metrics) this._metrics.gauge('pool.node_count', this._nodes.size); + } + } + + async shutdown(timeoutMs = 10000) { + if (this._reapInterval) clearInterval(this._reapInterval); + const deadline = Date.now() + timeoutMs; + const draining = []; + + for (const node of this._nodes.values()) { + if (!node.drain()) draining.push(node); + } + + while (draining.length > 0 && Date.now() < deadline) { + await new Promise(r => setTimeout(r, 100)); + for (let i = draining.length - 1; i >= 0; i--) { + if (draining[i].state !== NodeState.BUSY) { + draining[i].retire(); + draining.splice(i, 1); + } + } + } + + for (const node of draining) { + node.retire(); + if (this._logger) this._logger.warn('Node force-retired during shutdown', { nodeId: node.id }); + } + + this._nodes.clear(); + if (this._logger) this._logger.info('Node pool shut down'); + } + + status() { + const nodes = []; + for (const node of this._nodes.values()) nodes.push(node.status()); + return { + capacity: this._poolSize, + active: this._nodes.size, + idle: nodes.filter(n => n.state === NodeState.IDLE).length, + busy: nodes.filter(n => n.state === NodeState.BUSY).length, + nodes, + }; + } + + _reapIdle() { + const now = Date.now(); + for (const [id, node] of this._nodes) { + if (node.state === NodeState.IDLE && (now - node.lastActiveAt) > this._idleTtlMs) { + node.retire(); + this._nodes.delete(id); + if (this._logger) this._logger.debug('Idle node reaped', { nodeId: id }); + } + } + if (this._metrics) this._metrics.gauge('pool.node_count', this._nodes.size); + } +} + +module.exports = { NodePool }; diff --git a/src/execution/task.js b/src/execution/task.js new file mode 100644 index 0000000..9325d4c --- /dev/null +++ b/src/execution/task.js @@ -0,0 +1,59 @@ +'use strict'; + +const crypto = require('crypto'); +const { OperationalError, ErrorCodes } = require('../observability/errors'); + +const TaskStatus = Object.freeze({ + PENDING: 'pending', + RUNNING: 'running', + COMPLETED: 'completed', + FAILED: 'failed', + TIMED_OUT: 'timed_out', + CANCELLED: 'cancelled', +}); + +function createTaskEnvelope({ type, payload, capabilities = [], correlationId, timeoutMs }) { + if (!type || typeof type !== 'string') { + throw new OperationalError('Task type is required', ErrorCodes.TASK_INVALID, { type }); + } + if (!payload) { + throw new OperationalError('Task payload is required', ErrorCodes.TASK_INVALID, { type }); + } + return Object.freeze({ + id: crypto.randomUUID(), + type, + payload, + capabilities, + correlationId: correlationId || crypto.randomUUID(), + status: TaskStatus.PENDING, + createdAt: Date.now(), + timeoutMs: timeoutMs || 30000, + result: null, + error: null, + }); +} + +function transitionTask(task, newStatus, data = {}) { + const allowed = { + [TaskStatus.PENDING]: [TaskStatus.RUNNING, TaskStatus.CANCELLED], + [TaskStatus.RUNNING]: [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.TIMED_OUT, TaskStatus.CANCELLED], + }; + const valid = allowed[task.status]; + if (!valid || !valid.includes(newStatus)) { + throw new OperationalError( + `Invalid task transition: ${task.status} -> ${newStatus}`, + ErrorCodes.TASK_INVALID, + { taskId: task.id, from: task.status, to: newStatus } + ); + } + return Object.freeze({ + ...task, + status: newStatus, + result: data.result || task.result, + error: data.error || task.error, + completedAt: [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.TIMED_OUT, TaskStatus.CANCELLED].includes(newStatus) + ? Date.now() : undefined, + }); +} + +module.exports = { TaskStatus, createTaskEnvelope, transitionTask }; diff --git a/src/gateway/coordinator.js b/src/gateway/coordinator.js new file mode 100644 index 0000000..61fb0c4 --- /dev/null +++ b/src/gateway/coordinator.js @@ -0,0 +1,91 @@ +'use strict'; + +const { OperationalError, ErrorCodes } = require('../observability/errors'); +const { generateCorrelationId } = require('../observability/logger'); +const { TaskStatus } = require('../execution/task'); + +class AsyncCoordinator { + constructor({ router, pool, logger, metrics, maxConcurrency = 10 }) { + this._router = router; + this._pool = pool; + this._logger = logger; + this._metrics = metrics; + this._maxConcurrency = maxConcurrency; + this._activeTasks = new Map(); + this._taskHistory = []; + this._historyLimit = 1000; + } + + async submit(request) { + const correlationId = request.correlationId || generateCorrelationId(); + const { task, handler } = this._router.route({ ...request, correlationId }); + + if (this._activeTasks.size >= this._maxConcurrency) { + throw new OperationalError('Max concurrency reached', ErrorCodes.TASK_FAILED, { + active: this._activeTasks.size, + max: this._maxConcurrency, + }); + } + + this._activeTasks.set(task.id, { task, startedAt: Date.now() }); + if (this._metrics) this._metrics.gauge('coordinator.active_tasks', this._activeTasks.size); + + try { + const node = this._pool.acquire(task.capabilities); + const completedTask = await node.execute(task, handler); + this._recordCompletion(completedTask); + return completedTask; + } catch (err) { + const failedResult = { id: task.id, status: TaskStatus.FAILED, error: err.message, correlationId }; + this._recordCompletion(failedResult); + throw err; + } + } + + async submitBatch(requests) { + const independentGroups = this._partitionByDependency(requests); + const results = []; + + for (const group of independentGroups) { + const groupResults = await Promise.allSettled( + group.map(req => this.submit(req)) + ); + results.push(...groupResults.map((r, i) => ({ + request: group[i], + status: r.status, + value: r.status === 'fulfilled' ? r.value : undefined, + error: r.status === 'rejected' ? r.reason.message : undefined, + }))); + } + + return results; + } + + _partitionByDependency(requests) { + const withDeps = requests.filter(r => r.dependsOn && r.dependsOn.length > 0); + const noDeps = requests.filter(r => !r.dependsOn || r.dependsOn.length === 0); + const groups = [noDeps]; + if (withDeps.length > 0) groups.push(withDeps); + return groups.filter(g => g.length > 0); + } + + _recordCompletion(task) { + this._activeTasks.delete(task.id); + if (this._metrics) this._metrics.gauge('coordinator.active_tasks', this._activeTasks.size); + this._taskHistory.push({ id: task.id, status: task.status, completedAt: Date.now() }); + if (this._taskHistory.length > this._historyLimit) { + this._taskHistory = this._taskHistory.slice(-this._historyLimit); + } + } + + status() { + return { + activeTasks: this._activeTasks.size, + maxConcurrency: this._maxConcurrency, + totalCompleted: this._taskHistory.length, + recentHistory: this._taskHistory.slice(-10), + }; + } +} + +module.exports = { AsyncCoordinator }; diff --git a/src/gateway/index.js b/src/gateway/index.js new file mode 100644 index 0000000..7179083 --- /dev/null +++ b/src/gateway/index.js @@ -0,0 +1,6 @@ +'use strict'; + +const { CapabilityRouter } = require('./router'); +const { AsyncCoordinator } = require('./coordinator'); + +module.exports = { CapabilityRouter, AsyncCoordinator }; diff --git a/src/gateway/router.js b/src/gateway/router.js new file mode 100644 index 0000000..bc62944 --- /dev/null +++ b/src/gateway/router.js @@ -0,0 +1,73 @@ +'use strict'; + +const { OperationalError, ErrorCodes } = require('../observability/errors'); +const { createTaskEnvelope } = require('../execution/task'); + +class CapabilityRouter { + constructor({ logger, metrics }) { + this._handlers = new Map(); + this._logger = logger; + this._metrics = metrics; + } + + register(taskType, { capabilities = [], handler, validate }) { + if (this._handlers.has(taskType)) { + throw new OperationalError(`Handler already registered for type: ${taskType}`, ErrorCodes.ROUTING_FAILED); + } + if (typeof handler !== 'function') { + throw new OperationalError(`Handler must be a function for type: ${taskType}`, ErrorCodes.ROUTING_FAILED); + } + this._handlers.set(taskType, { capabilities, handler, validate: validate || null }); + if (this._logger) this._logger.info('Handler registered', { taskType, capabilities }); + } + + unregister(taskType) { + this._handlers.delete(taskType); + } + + resolve(taskType) { + const registration = this._handlers.get(taskType); + if (!registration) { + throw new OperationalError(`No handler for task type: ${taskType}`, ErrorCodes.CAPABILITY_NOT_FOUND, { taskType }); + } + return registration; + } + + route(request) { + const { type, payload, correlationId, timeoutMs } = request; + const registration = this.resolve(type); + + if (registration.validate) { + const validationResult = registration.validate(payload); + if (validationResult !== true && validationResult !== undefined) { + throw new OperationalError( + `Validation failed for task type: ${type}: ${validationResult}`, + ErrorCodes.VALIDATION_FAILED, + { taskType: type, validationResult } + ); + } + } + + const task = createTaskEnvelope({ + type, + payload, + capabilities: registration.capabilities, + correlationId, + timeoutMs, + }); + + if (this._metrics) this._metrics.increment('router.tasks_routed', 1, { type }); + + return { task, handler: registration.handler }; + } + + listCapabilities() { + const result = {}; + for (const [type, reg] of this._handlers) { + result[type] = { capabilities: reg.capabilities, hasValidation: !!reg.validate }; + } + return result; + } +} + +module.exports = { CapabilityRouter }; diff --git a/src/interface/index.js b/src/interface/index.js new file mode 100644 index 0000000..4ca1172 --- /dev/null +++ b/src/interface/index.js @@ -0,0 +1,6 @@ +'use strict'; + +const { correlationMiddleware, securityHeaders, corsMiddleware, requestLogger, errorHandler, inputValidator } = require('./middleware'); +const { createRoutes } = require('./routes'); + +module.exports = { correlationMiddleware, securityHeaders, corsMiddleware, requestLogger, errorHandler, inputValidator, createRoutes }; diff --git a/src/interface/middleware.js b/src/interface/middleware.js new file mode 100644 index 0000000..8df749d --- /dev/null +++ b/src/interface/middleware.js @@ -0,0 +1,106 @@ +'use strict'; + +const { generateCorrelationId } = require('../observability/logger'); + +function correlationMiddleware() { + return (req, res, next) => { + req.correlationId = req.headers['x-correlation-id'] || generateCorrelationId(); + res.setHeader('x-correlation-id', req.correlationId); + next(); + }; +} + +function securityHeaders() { + return (req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('X-XSS-Protection', '0'); + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'"); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.removeHeader('X-Powered-By'); + next(); + }; +} + +function corsMiddleware(allowedOrigins = []) { + return (req, res, next) => { + const origin = req.headers.origin; + if (origin && allowedOrigins.length > 0) { + if (allowedOrigins.includes(origin) || allowedOrigins.includes('*')) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Correlation-Id'); + res.setHeader('Access-Control-Max-Age', '86400'); + } + } + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + next(); + }; +} + +function requestLogger(logger) { + return (req, res, next) => { + const start = Date.now(); + res.on('finish', () => { + logger.info('request', { + method: req.method, + path: req.path, + statusCode: res.statusCode, + durationMs: Date.now() - start, + correlationId: req.correlationId, + }); + }); + next(); + }; +} + +function errorHandler(logger) { + return (err, req, res, _next) => { + const statusCode = err.operational ? 400 : 500; + const code = err.code || 'INTERNAL_ERROR'; + + logger.error('Unhandled error', { + error: err.message, + code, + stack: err.operational ? undefined : err.stack, + correlationId: req.correlationId, + }); + + res.status(statusCode).json({ + error: code, + message: err.operational ? err.message : 'Internal server error', + correlationId: req.correlationId, + }); + }; +} + +function inputValidator(schema) { + return (req, res, next) => { + if (!schema) return next(); + const errors = []; + for (const [field, rules] of Object.entries(schema)) { + const value = req.body[field]; + if (rules.required && (value === undefined || value === null || value === '')) { + errors.push(`${field} is required`); + continue; + } + if (value !== undefined && rules.type && typeof value !== rules.type) { + errors.push(`${field} must be of type ${rules.type}`); + } + if (value !== undefined && rules.maxLength && typeof value === 'string' && value.length > rules.maxLength) { + errors.push(`${field} must be at most ${rules.maxLength} characters`); + } + } + if (errors.length > 0) { + return res.status(400).json({ error: 'VALIDATION_FAILED', messages: errors, correlationId: req.correlationId }); + } + next(); + }; +} + +module.exports = { correlationMiddleware, securityHeaders, corsMiddleware, requestLogger, errorHandler, inputValidator }; diff --git a/src/interface/routes.js b/src/interface/routes.js new file mode 100644 index 0000000..badc27d --- /dev/null +++ b/src/interface/routes.js @@ -0,0 +1,122 @@ +'use strict'; + +const express = require('express'); +const { inputValidator } = require('./middleware'); + +function createRoutes({ kernel }) { + const router = express.Router(); + + router.get('/health', (req, res) => { + res.json({ + status: 'healthy', + service: 'headyos-core', + version: kernel.version, + uptime: process.uptime(), + timestamp: new Date().toISOString(), + }); + }); + + router.get('/readiness', (req, res) => { + const ready = kernel.isReady(); + res.status(ready ? 200 : 503).json({ + ready, + service: 'headyos-core', + timestamp: new Date().toISOString(), + }); + }); + + router.get('/status', (req, res) => { + res.json(kernel.status()); + }); + + router.get('/capabilities', (req, res) => { + res.json(kernel.capabilities()); + }); + + router.get('/metrics', (req, res) => { + res.json(kernel.metricsSnapshot()); + }); + + router.get('/docs', (req, res) => { + res.json({ + service: 'headyos-core', + version: kernel.version, + description: 'HeadyOS Core — Latent Operating System control plane', + endpoints: [ + { method: 'GET', path: '/health', description: 'Health check' }, + { method: 'GET', path: '/readiness', description: 'Readiness probe' }, + { method: 'GET', path: '/status', description: 'System status with pool and coordinator info' }, + { method: 'GET', path: '/capabilities', description: 'Registered task capabilities' }, + { method: 'GET', path: '/metrics', description: 'Runtime metrics snapshot' }, + { method: 'GET', path: '/docs', description: 'API documentation' }, + { method: 'POST', path: '/tasks', description: 'Submit a task for execution' }, + { method: 'POST', path: '/tasks/batch', description: 'Submit a batch of tasks' }, + { method: 'POST', path: '/pipeline', description: 'Execute an orchestration pipeline' }, + ], + configuration: { + envVars: [ + 'PORT', 'NODE_ENV', 'LOG_LEVEL', 'CORS_ORIGINS', 'MAX_CONCURRENCY', + 'TASK_TIMEOUT_MS', 'NODE_POOL_SIZE', 'NODE_IDLE_TTL_MS', 'MEMORY_ADAPTER', + 'METRICS_ENABLED', 'SHUTDOWN_TIMEOUT_MS', + ], + }, + }); + }); + + router.post('/tasks', + inputValidator({ type: { required: true, type: 'string', maxLength: 256 }, payload: { required: true, type: 'object' } }), + async (req, res, next) => { + try { + const result = await kernel.submitTask({ + type: req.body.type, + payload: req.body.payload, + correlationId: req.correlationId, + timeoutMs: req.body.timeoutMs, + }); + res.status(200).json(result); + } catch (err) { + next(err); + } + } + ); + + router.post('/tasks/batch', + inputValidator({ tasks: { required: true } }), + async (req, res, next) => { + try { + if (!Array.isArray(req.body.tasks)) { + return res.status(400).json({ error: 'VALIDATION_FAILED', messages: ['tasks must be an array'] }); + } + const results = await kernel.submitBatch( + req.body.tasks.map(t => ({ ...t, correlationId: req.correlationId })) + ); + res.status(200).json({ results }); + } catch (err) { + next(err); + } + } + ); + + router.post('/pipeline', + inputValidator({ name: { required: true, type: 'string' }, steps: { required: true } }), + async (req, res, next) => { + try { + if (!Array.isArray(req.body.steps)) { + return res.status(400).json({ error: 'VALIDATION_FAILED', messages: ['steps must be an array'] }); + } + const result = await kernel.executePipeline({ + name: req.body.name, + steps: req.body.steps, + correlationId: req.correlationId, + }); + res.status(200).json(result); + } catch (err) { + next(err); + } + } + ); + + return router; +} + +module.exports = { createRoutes }; diff --git a/src/kernel.js b/src/kernel.js new file mode 100644 index 0000000..0cc6a8f --- /dev/null +++ b/src/kernel.js @@ -0,0 +1,144 @@ +'use strict'; + +const { loadConfig } = require('./config'); +const { createLogger, MetricsCollector } = require('./observability'); +const { NodePool } = require('./execution'); +const { CapabilityRouter, AsyncCoordinator } = require('./gateway'); +const { OrchestrationPipeline, createPlan } = require('./orchestration'); +const { createMemoryAdapter } = require('./memory'); + +const pkg = require('../package.json'); + +class Kernel { + constructor(env = process.env) { + this.config = loadConfig(env); + this.version = pkg.version; + this.startedAt = null; + this._ready = false; + + this.logger = createLogger({ level: this.config.LOG_LEVEL, service: 'headyos-core' }); + this.metrics = new MetricsCollector(); + this.memory = createMemoryAdapter(this.config.MEMORY_ADAPTER); + + this.pool = new NodePool({ + poolSize: this.config.NODE_POOL_SIZE, + idleTtlMs: this.config.NODE_IDLE_TTL_MS, + logger: this.logger, + metrics: this.metrics, + taskTimeoutMs: this.config.TASK_TIMEOUT_MS, + }); + + this.router = new CapabilityRouter({ logger: this.logger, metrics: this.metrics }); + this.coordinator = new AsyncCoordinator({ + router: this.router, + pool: this.pool, + logger: this.logger, + metrics: this.metrics, + maxConcurrency: this.config.MAX_CONCURRENCY, + }); + + this.pipeline = new OrchestrationPipeline({ + coordinator: this.coordinator, + logger: this.logger, + metrics: this.metrics, + }); + + this._registerBuiltinHandlers(); + } + + boot() { + this.pool.start(); + this.startedAt = Date.now(); + this._ready = true; + this.logger.info('Kernel booted', { version: this.version, env: this.config.NODE_ENV }); + } + + async shutdown() { + this._ready = false; + this.logger.info('Kernel shutting down'); + await this.pool.shutdown(this.config.SHUTDOWN_TIMEOUT_MS); + await this.memory.close(); + this.logger.info('Kernel shut down complete'); + } + + isReady() { + return this._ready; + } + + async submitTask(request) { + return this.coordinator.submit(request); + } + + async submitBatch(requests) { + return this.coordinator.submitBatch(requests); + } + + async executePipeline(planDef) { + const plan = createPlan(planDef); + return this.pipeline.execute(plan); + } + + capabilities() { + return this.router.listCapabilities(); + } + + metricsSnapshot() { + return this.metrics.snapshot(); + } + + status() { + return { + service: 'headyos-core', + version: this.version, + env: this.config.NODE_ENV, + uptime: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1000) : 0, + ready: this._ready, + pool: this.pool.status(), + coordinator: this.coordinator.status(), + memory: { adapter: this.config.MEMORY_ADAPTER }, + timestamp: new Date().toISOString(), + }; + } + + _registerBuiltinHandlers() { + this.router.register('echo', { + capabilities: [], + handler: async (payload) => ({ echo: payload }), + validate: (payload) => payload !== undefined || 'Payload required', + }); + + this.router.register('memory.get', { + capabilities: [], + handler: async (payload) => { + const value = await this.memory.get(payload.key); + return { key: payload.key, value }; + }, + validate: (payload) => (payload && payload.key) || 'key is required', + }); + + this.router.register('memory.set', { + capabilities: [], + handler: async (payload) => { + await this.memory.set(payload.key, payload.value, payload.ttlMs); + return { key: payload.key, stored: true }; + }, + validate: (payload) => (payload && payload.key && payload.value !== undefined) || 'key and value are required', + }); + + this.router.register('memory.delete', { + capabilities: [], + handler: async (payload) => { + const deleted = await this.memory.delete(payload.key); + return { key: payload.key, deleted }; + }, + validate: (payload) => (payload && payload.key) || 'key is required', + }); + + this.router.register('status', { + capabilities: [], + handler: async () => this.status(), + }); + } +} + +module.exports = { Kernel }; diff --git a/src/memory/adapter.js b/src/memory/adapter.js new file mode 100644 index 0000000..5293c69 --- /dev/null +++ b/src/memory/adapter.js @@ -0,0 +1,108 @@ +'use strict'; + +const { OperationalError, ErrorCodes } = require('../observability/errors'); + +class MemoryAdapter { + async get(key) { throw new Error('Not implemented'); } + async set(key, value, ttlMs) { throw new Error('Not implemented'); } + async delete(key) { throw new Error('Not implemented'); } + async has(key) { throw new Error('Not implemented'); } + async clear() { throw new Error('Not implemented'); } + async keys(pattern) { throw new Error('Not implemented'); } + async close() {} +} + +class InMemoryAdapter extends MemoryAdapter { + constructor() { + super(); + this._store = new Map(); + this._timers = new Map(); + } + + async get(key) { + const entry = this._store.get(key); + if (!entry) return null; + if (entry.expiresAt && Date.now() > entry.expiresAt) { + this._store.delete(key); + this._clearTimer(key); + return null; + } + return entry.value; + } + + async set(key, value, ttlMs) { + this._clearTimer(key); + const entry = { value, createdAt: Date.now(), expiresAt: ttlMs ? Date.now() + ttlMs : null }; + this._store.set(key, entry); + if (ttlMs) { + const timer = setTimeout(() => { + this._store.delete(key); + this._timers.delete(key); + }, ttlMs); + if (timer.unref) timer.unref(); + this._timers.set(key, timer); + } + } + + async delete(key) { + this._clearTimer(key); + return this._store.delete(key); + } + + async has(key) { + const val = await this.get(key); + return val !== null; + } + + async clear() { + for (const timer of this._timers.values()) clearTimeout(timer); + this._timers.clear(); + this._store.clear(); + } + + async keys(pattern) { + const allKeys = [...this._store.keys()]; + if (!pattern) return allKeys; + const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); + return allKeys.filter(k => regex.test(k)); + } + + async close() { + await this.clear(); + } + + size() { + return this._store.size; + } + + _clearTimer(key) { + const timer = this._timers.get(key); + if (timer) { + clearTimeout(timer); + this._timers.delete(key); + } + } +} + +function createMemoryAdapter(type = 'in-memory') { + switch (type) { + case 'in-memory': + return new InMemoryAdapter(); + case 'redis': + throw new OperationalError( + 'Redis adapter requires @heady/memory-redis package', + ErrorCodes.MEMORY_ADAPTER_ERROR, + { adapter: 'redis', hint: 'Install @heady/memory-redis and set MEMORY_ADAPTER=redis' } + ); + case 'postgres': + throw new OperationalError( + 'Postgres adapter requires @heady/memory-postgres package', + ErrorCodes.MEMORY_ADAPTER_ERROR, + { adapter: 'postgres', hint: 'Install @heady/memory-postgres and set MEMORY_ADAPTER=postgres' } + ); + default: + throw new OperationalError(`Unknown memory adapter: ${type}`, ErrorCodes.MEMORY_ADAPTER_ERROR, { adapter: type }); + } +} + +module.exports = { MemoryAdapter, InMemoryAdapter, createMemoryAdapter }; diff --git a/src/memory/index.js b/src/memory/index.js new file mode 100644 index 0000000..af54500 --- /dev/null +++ b/src/memory/index.js @@ -0,0 +1,5 @@ +'use strict'; + +const { MemoryAdapter, InMemoryAdapter, createMemoryAdapter } = require('./adapter'); + +module.exports = { MemoryAdapter, InMemoryAdapter, createMemoryAdapter }; diff --git a/src/observability/errors.js b/src/observability/errors.js new file mode 100644 index 0000000..a7d04e7 --- /dev/null +++ b/src/observability/errors.js @@ -0,0 +1,41 @@ +'use strict'; + +class OperationalError extends Error { + constructor(message, code = 'UNKNOWN_ERROR', context = {}) { + super(message); + this.name = 'OperationalError'; + this.code = code; + this.context = context; + this.operational = true; + this.timestamp = new Date().toISOString(); + } + + toJSON() { + return { + error: this.code, + message: this.message, + context: this.context, + timestamp: this.timestamp, + }; + } +} + +const ErrorCodes = Object.freeze({ + CONFIG_MISSING: 'CONFIG_MISSING', + CONFIG_INVALID: 'CONFIG_INVALID', + CONFIG_VALIDATION_FAILED: 'CONFIG_VALIDATION_FAILED', + TASK_TIMEOUT: 'TASK_TIMEOUT', + TASK_FAILED: 'TASK_FAILED', + TASK_INVALID: 'TASK_INVALID', + NODE_UNAVAILABLE: 'NODE_UNAVAILABLE', + NODE_SPAWN_FAILED: 'NODE_SPAWN_FAILED', + CAPABILITY_NOT_FOUND: 'CAPABILITY_NOT_FOUND', + ROUTING_FAILED: 'ROUTING_FAILED', + MEMORY_ADAPTER_ERROR: 'MEMORY_ADAPTER_ERROR', + ORCHESTRATION_FAILED: 'ORCHESTRATION_FAILED', + VALIDATION_FAILED: 'VALIDATION_FAILED', + SHUTDOWN_TIMEOUT: 'SHUTDOWN_TIMEOUT', + INTERNAL_ERROR: 'INTERNAL_ERROR', +}); + +module.exports = { OperationalError, ErrorCodes }; diff --git a/src/observability/index.js b/src/observability/index.js new file mode 100644 index 0000000..6fb6ec2 --- /dev/null +++ b/src/observability/index.js @@ -0,0 +1,14 @@ +'use strict'; + +const { createLogger, generateCorrelationId, LEVELS } = require('./logger'); +const { MetricsCollector } = require('./metrics'); +const { OperationalError, ErrorCodes } = require('./errors'); + +module.exports = { + createLogger, + generateCorrelationId, + LEVELS, + MetricsCollector, + OperationalError, + ErrorCodes, +}; diff --git a/src/observability/logger.js b/src/observability/logger.js new file mode 100644 index 0000000..1b605a7 --- /dev/null +++ b/src/observability/logger.js @@ -0,0 +1,40 @@ +'use strict'; + +const crypto = require('crypto'); + +const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; + +function createLogger(opts = {}) { + const minLevel = LEVELS[opts.level || 'info'] || 0; + const service = opts.service || 'headyos-core'; + const stream = opts.stream || process.stdout; + + function write(level, message, meta = {}) { + if (LEVELS[level] < minLevel) return; + const entry = { + timestamp: new Date().toISOString(), + level, + service, + message, + ...meta, + }; + if (meta.correlationId) entry.correlationId = meta.correlationId; + stream.write(JSON.stringify(entry) + '\n'); + } + + return { + debug: (msg, meta) => write('debug', msg, meta), + info: (msg, meta) => write('info', msg, meta), + warn: (msg, meta) => write('warn', msg, meta), + error: (msg, meta) => write('error', msg, meta), + child(extra) { + return createLogger({ ...opts, _extraMeta: { ...opts._extraMeta, ...extra } }); + }, + }; +} + +function generateCorrelationId() { + return crypto.randomUUID(); +} + +module.exports = { createLogger, generateCorrelationId, LEVELS }; diff --git a/src/observability/metrics.js b/src/observability/metrics.js new file mode 100644 index 0000000..2774234 --- /dev/null +++ b/src/observability/metrics.js @@ -0,0 +1,58 @@ +'use strict'; + +class MetricsCollector { + constructor() { + this._counters = new Map(); + this._gauges = new Map(); + this._histograms = new Map(); + } + + increment(name, value = 1, tags = {}) { + const key = this._key(name, tags); + this._counters.set(key, (this._counters.get(key) || 0) + value); + } + + gauge(name, value, tags = {}) { + const key = this._key(name, tags); + this._gauges.set(key, value); + } + + histogram(name, value, tags = {}) { + const key = this._key(name, tags); + if (!this._histograms.has(key)) this._histograms.set(key, []); + this._histograms.get(key).push(value); + } + + snapshot() { + return { + counters: Object.fromEntries(this._counters), + gauges: Object.fromEntries(this._gauges), + histograms: Object.fromEntries( + Array.from(this._histograms.entries()).map(([k, vals]) => { + const sorted = [...vals].sort((a, b) => a - b); + return [k, { + count: sorted.length, + min: sorted[0], + max: sorted[sorted.length - 1], + p50: sorted[Math.floor(sorted.length * 0.5)], + p95: sorted[Math.floor(sorted.length * 0.95)], + p99: sorted[Math.floor(sorted.length * 0.99)], + }]; + }) + ), + }; + } + + reset() { + this._counters.clear(); + this._gauges.clear(); + this._histograms.clear(); + } + + _key(name, tags) { + const tagStr = Object.entries(tags).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join(','); + return tagStr ? `${name}{${tagStr}}` : name; + } +} + +module.exports = { MetricsCollector }; diff --git a/src/orchestration/index.js b/src/orchestration/index.js new file mode 100644 index 0000000..da16106 --- /dev/null +++ b/src/orchestration/index.js @@ -0,0 +1,5 @@ +'use strict'; + +const { OrchestrationPipeline, createPlan } = require('./pipeline'); + +module.exports = { OrchestrationPipeline, createPlan }; diff --git a/src/orchestration/pipeline.js b/src/orchestration/pipeline.js new file mode 100644 index 0000000..d94db0f --- /dev/null +++ b/src/orchestration/pipeline.js @@ -0,0 +1,84 @@ +'use strict'; + +const { OperationalError, ErrorCodes } = require('../observability/errors'); +const { generateCorrelationId } = require('../observability/logger'); + +class OrchestrationPipeline { + constructor({ coordinator, logger, metrics }) { + this._coordinator = coordinator; + this._logger = logger; + this._metrics = metrics; + } + + async execute(plan) { + const correlationId = plan.correlationId || generateCorrelationId(); + const startTime = Date.now(); + + if (this._logger) this._logger.info('Pipeline execution started', { correlationId, steps: plan.steps.length }); + + const results = []; + + for (const step of plan.steps) { + const stepStart = Date.now(); + + if (step.parallel && Array.isArray(step.tasks)) { + const batchResults = await this._coordinator.submitBatch( + step.tasks.map(t => ({ ...t, correlationId })) + ); + results.push({ + step: step.name, + type: 'parallel', + results: batchResults, + durationMs: Date.now() - stepStart, + }); + + const failures = batchResults.filter(r => r.status === 'rejected'); + if (failures.length > 0 && step.required !== false) { + throw new OperationalError( + `Pipeline step "${step.name}" failed: ${failures.length} task(s) failed`, + ErrorCodes.ORCHESTRATION_FAILED, + { step: step.name, failures: failures.map(f => f.error), correlationId } + ); + } + } else { + try { + const taskResult = await this._coordinator.submit({ ...step.task, correlationId }); + results.push({ + step: step.name, + type: 'sequential', + result: taskResult, + durationMs: Date.now() - stepStart, + }); + } catch (err) { + if (step.required !== false) throw err; + results.push({ + step: step.name, + type: 'sequential', + error: err.message, + durationMs: Date.now() - stepStart, + }); + } + } + } + + const report = { + correlationId, + status: 'completed', + steps: results, + totalDurationMs: Date.now() - startTime, + }; + + if (this._logger) this._logger.info('Pipeline execution completed', { correlationId, durationMs: report.totalDurationMs }); + if (this._metrics) this._metrics.histogram('pipeline.duration_ms', report.totalDurationMs); + + return report; + } +} + +function createPlan({ name, steps, correlationId }) { + if (!name) throw new OperationalError('Plan name is required', ErrorCodes.ORCHESTRATION_FAILED); + if (!steps || steps.length === 0) throw new OperationalError('Plan must have at least one step', ErrorCodes.ORCHESTRATION_FAILED); + return { name, steps, correlationId: correlationId || generateCorrelationId() }; +} + +module.exports = { OrchestrationPipeline, createPlan }; diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..3d855bd --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,48 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { describe, it } = require('node:test'); +const { loadConfig, getConfigSchema } = require('../src/config'); + +describe('Config', () => { + it('loads defaults with empty env', () => { + const config = loadConfig({}); + assert.equal(config.PORT, 8080); + assert.equal(config.NODE_ENV, 'production'); + assert.equal(config.LOG_LEVEL, 'info'); + assert.equal(config.MAX_CONCURRENCY, 10); + assert.equal(config.MEMORY_ADAPTER, 'in-memory'); + assert.deepEqual(config.CORS_ORIGINS, ''); + }); + + it('parses env overrides', () => { + const config = loadConfig({ PORT: '3000', NODE_ENV: 'test', LOG_LEVEL: 'debug', CORS_ORIGINS: 'https://a.com,https://b.com' }); + assert.equal(config.PORT, 3000); + assert.equal(config.NODE_ENV, 'test'); + assert.equal(config.LOG_LEVEL, 'debug'); + assert.deepEqual(config.CORS_ORIGINS, ['https://a.com', 'https://b.com']); + }); + + it('rejects invalid PORT', () => { + assert.throws(() => loadConfig({ PORT: 'abc' }), /must be a number/); + }); + + it('rejects invalid NODE_ENV', () => { + assert.throws(() => loadConfig({ NODE_ENV: 'invalid' }), /must be one of/); + }); + + it('rejects PORT out of range', () => { + assert.throws(() => loadConfig({ PORT: '99999' }), /must be <= 65535/); + }); + + it('returns frozen config', () => { + const config = loadConfig({}); + assert.throws(() => { config.PORT = 9999; }, TypeError); + }); + + it('exposes schema', () => { + const schema = getConfigSchema(); + assert.ok(schema.PORT); + assert.ok(schema.NODE_ENV); + }); +}); diff --git a/test/execution.test.js b/test/execution.test.js new file mode 100644 index 0000000..4ba0aa4 --- /dev/null +++ b/test/execution.test.js @@ -0,0 +1,106 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { describe, it } = require('node:test'); +const { createTaskEnvelope, TaskStatus, transitionTask } = require('../src/execution/task'); +const { LiquidNode, NodeState } = require('../src/execution/node'); +const { NodePool } = require('../src/execution/pool'); + +describe('Task Envelope', () => { + it('creates a valid envelope', () => { + const task = createTaskEnvelope({ type: 'test', payload: { data: 1 } }); + assert.ok(task.id); + assert.equal(task.type, 'test'); + assert.equal(task.status, TaskStatus.PENDING); + assert.ok(task.correlationId); + }); + + it('rejects missing type', () => { + assert.throws(() => createTaskEnvelope({ payload: {} }), /type is required/); + }); + + it('rejects missing payload', () => { + assert.throws(() => createTaskEnvelope({ type: 'test' }), /payload is required/); + }); + + it('transitions correctly', () => { + const task = createTaskEnvelope({ type: 'test', payload: {} }); + const running = transitionTask(task, TaskStatus.RUNNING); + assert.equal(running.status, TaskStatus.RUNNING); + const completed = transitionTask(running, TaskStatus.COMPLETED, { result: 'ok' }); + assert.equal(completed.status, TaskStatus.COMPLETED); + assert.equal(completed.result, 'ok'); + }); + + it('rejects invalid transitions', () => { + const task = createTaskEnvelope({ type: 'test', payload: {} }); + assert.throws(() => transitionTask(task, TaskStatus.COMPLETED), /Invalid task transition/); + }); +}); + +describe('LiquidNode', () => { + it('executes a task successfully', async () => { + const node = new LiquidNode({ capabilities: ['compute'] }); + const task = createTaskEnvelope({ type: 'test', payload: { x: 1 }, capabilities: ['compute'] }); + const result = await node.execute(task, async (payload) => ({ doubled: payload.x * 2 })); + assert.equal(result.status, TaskStatus.COMPLETED); + assert.deepEqual(result.result, { doubled: 2 }); + assert.equal(node.state, NodeState.IDLE); + assert.equal(node.tasksProcessed, 1); + }); + + it('handles task failure', async () => { + const node = new LiquidNode({ capabilities: [] }); + const task = createTaskEnvelope({ type: 'test', payload: {} }); + const result = await node.execute(task, async () => { throw new Error('boom'); }); + assert.equal(result.status, TaskStatus.FAILED); + assert.equal(result.error, 'boom'); + }); + + it('handles task timeout', async () => { + const node = new LiquidNode({ capabilities: [], timeoutMs: 50 }); + const task = createTaskEnvelope({ type: 'test', payload: {}, timeoutMs: 50 }); + const result = await node.execute(task, async (_, { signal }) => { + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 5000); + signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('aborted')); }); + }); + }); + assert.equal(result.status, TaskStatus.TIMED_OUT); + }); + + it('checks capability matching', () => { + const node = new LiquidNode({ capabilities: ['a', 'b'] }); + assert.ok(node.canHandle(['a'])); + assert.ok(node.canHandle(['a', 'b'])); + assert.ok(!node.canHandle(['c'])); + }); +}); + +describe('NodePool', () => { + it('spawns and acquires nodes', () => { + const pool = new NodePool({ poolSize: 3 }); + pool.start(); + const node = pool.acquire(['compute']); + assert.ok(node); + assert.equal(pool.status().active, 1); + }); + + it('enforces pool size limit', () => { + const pool = new NodePool({ poolSize: 1, idleTtlMs: 999999 }); + pool.start(); + const node = pool.acquire(); + // Mark it busy to prevent reaping + node.state = NodeState.BUSY; + assert.throws(() => pool.acquire(), /pool at capacity/i); + }); + + it('shuts down gracefully', async () => { + const pool = new NodePool({ poolSize: 3 }); + pool.start(); + pool.acquire(); + pool.acquire(); + await pool.shutdown(1000); + assert.equal(pool.status().active, 0); + }); +}); diff --git a/test/gateway.test.js b/test/gateway.test.js new file mode 100644 index 0000000..e2d79cf --- /dev/null +++ b/test/gateway.test.js @@ -0,0 +1,79 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { describe, it } = require('node:test'); +const { CapabilityRouter } = require('../src/gateway/router'); +const { AsyncCoordinator } = require('../src/gateway/coordinator'); +const { NodePool } = require('../src/execution/pool'); +const { TaskStatus } = require('../src/execution/task'); + +describe('CapabilityRouter', () => { + it('registers and resolves handlers', () => { + const router = new CapabilityRouter({}); + router.register('echo', { capabilities: [], handler: async (p) => p }); + const reg = router.resolve('echo'); + assert.ok(reg.handler); + }); + + it('throws on unknown task type', () => { + const router = new CapabilityRouter({}); + assert.throws(() => router.resolve('unknown'), /No handler/); + }); + + it('validates payload', () => { + const router = new CapabilityRouter({}); + router.register('strict', { + capabilities: [], + handler: async () => {}, + validate: (p) => (p && p.name) || 'name is required', + }); + assert.throws(() => router.route({ type: 'strict', payload: {} }), /name is required/); + }); + + it('routes valid task', () => { + const router = new CapabilityRouter({}); + router.register('echo', { capabilities: [], handler: async (p) => p }); + const { task, handler } = router.route({ type: 'echo', payload: { msg: 'hi' } }); + assert.equal(task.type, 'echo'); + assert.ok(handler); + }); + + it('lists capabilities', () => { + const router = new CapabilityRouter({}); + router.register('a', { capabilities: ['x'], handler: async () => {} }); + router.register('b', { capabilities: ['y', 'z'], handler: async () => {} }); + const caps = router.listCapabilities(); + assert.deepEqual(caps.a.capabilities, ['x']); + assert.deepEqual(caps.b.capabilities, ['y', 'z']); + }); +}); + +describe('AsyncCoordinator', () => { + it('submits and completes a task', async () => { + const router = new CapabilityRouter({}); + router.register('echo', { capabilities: [], handler: async (payload) => ({ echo: payload }) }); + const pool = new NodePool({ poolSize: 5 }); + pool.start(); + const coord = new AsyncCoordinator({ router, pool, maxConcurrency: 10 }); + const result = await coord.submit({ type: 'echo', payload: { msg: 'hello' } }); + assert.equal(result.status, TaskStatus.COMPLETED); + assert.deepEqual(result.result, { echo: { msg: 'hello' } }); + await pool.shutdown(); + }); + + it('submits a batch of tasks', async () => { + const router = new CapabilityRouter({}); + router.register('double', { capabilities: [], handler: async (p) => ({ val: p.n * 2 }) }); + const pool = new NodePool({ poolSize: 10 }); + pool.start(); + const coord = new AsyncCoordinator({ router, pool, maxConcurrency: 10 }); + const results = await coord.submitBatch([ + { type: 'double', payload: { n: 1 } }, + { type: 'double', payload: { n: 2 } }, + { type: 'double', payload: { n: 3 } }, + ]); + const values = results.filter(r => r.status === 'fulfilled').map(r => r.value.result.val); + assert.deepEqual(values.sort(), [2, 4, 6]); + await pool.shutdown(); + }); +}); diff --git a/test/integration.test.js b/test/integration.test.js new file mode 100644 index 0000000..ced9969 --- /dev/null +++ b/test/integration.test.js @@ -0,0 +1,142 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { describe, it, before, after } = require('node:test'); +const http = require('node:http'); +const { createApp } = require('../src/app'); + +let app, kernel, server; + +function request(path, options = {}) { + return new Promise((resolve, reject) => { + const opts = { + hostname: '127.0.0.1', + port: server.address().port, + path, + method: options.method || 'GET', + headers: { 'Content-Type': 'application/json', ...options.headers }, + }; + const req = http.request(opts, (res) => { + let body = ''; + res.on('data', chunk => { body += chunk; }); + res.on('end', () => { + try { + resolve({ statusCode: res.statusCode, headers: res.headers, body: JSON.parse(body) }); + } catch { + resolve({ statusCode: res.statusCode, headers: res.headers, body }); + } + }); + }); + req.on('error', reject); + if (options.body) req.write(JSON.stringify(options.body)); + req.end(); + }); +} + +describe('HTTP Integration', () => { + before(async () => { + const result = createApp({ + PORT: '9876', + NODE_ENV: 'test', + LOG_LEVEL: 'error', + }); + app = result.app; + kernel = result.kernel; + await new Promise((resolve) => { + server = app.listen(0, resolve); + }); + }); + + after(async () => { + await new Promise((resolve) => server.close(resolve)); + await kernel.shutdown(); + }); + + it('GET /health returns healthy', async () => { + const res = await request('/health'); + assert.equal(res.statusCode, 200); + assert.equal(res.body.status, 'healthy'); + assert.equal(res.body.service, 'headyos-core'); + assert.ok(res.body.uptime !== undefined); + }); + + it('GET /readiness returns ready', async () => { + const res = await request('/readiness'); + assert.equal(res.statusCode, 200); + assert.equal(res.body.ready, true); + }); + + it('GET /status returns system status', async () => { + const res = await request('/status'); + assert.equal(res.statusCode, 200); + assert.ok(res.body.pool); + assert.ok(res.body.coordinator); + assert.equal(res.body.service, 'headyos-core'); + }); + + it('GET /capabilities returns registered handlers', async () => { + const res = await request('/capabilities'); + assert.equal(res.statusCode, 200); + assert.ok(res.body.echo); + assert.ok(res.body['memory.get']); + }); + + it('GET /docs returns API documentation', async () => { + const res = await request('/docs'); + assert.equal(res.statusCode, 200); + assert.ok(Array.isArray(res.body.endpoints)); + assert.ok(res.body.endpoints.length > 0); + }); + + it('GET /metrics returns metrics snapshot', async () => { + const res = await request('/metrics'); + assert.equal(res.statusCode, 200); + assert.ok(res.body.counters !== undefined); + }); + + it('POST /tasks executes echo task', async () => { + const res = await request('/tasks', { + method: 'POST', + body: { type: 'echo', payload: { message: 'hello' } }, + }); + assert.equal(res.statusCode, 200); + assert.equal(res.body.status, 'completed'); + assert.deepEqual(res.body.result, { echo: { message: 'hello' } }); + }); + + it('POST /tasks validates input', async () => { + const res = await request('/tasks', { + method: 'POST', + body: { payload: { message: 'hello' } }, + }); + assert.equal(res.statusCode, 400); + assert.equal(res.body.error, 'VALIDATION_FAILED'); + }); + + it('POST /tasks/batch processes multiple tasks', async () => { + const res = await request('/tasks/batch', { + method: 'POST', + body: { + tasks: [ + { type: 'echo', payload: { n: 1 } }, + { type: 'echo', payload: { n: 2 } }, + ], + }, + }); + assert.equal(res.statusCode, 200); + assert.equal(res.body.results.length, 2); + }); + + it('security headers are present', async () => { + const res = await request('/health'); + assert.equal(res.headers['x-content-type-options'], 'nosniff'); + assert.equal(res.headers['x-frame-options'], 'DENY'); + assert.ok(res.headers['x-correlation-id']); + assert.ok(res.headers['strict-transport-security']); + }); + + it('correlation ID is propagated', async () => { + const res = await request('/health', { headers: { 'x-correlation-id': 'test-123' } }); + assert.equal(res.headers['x-correlation-id'], 'test-123'); + }); +}); diff --git a/test/memory.test.js b/test/memory.test.js new file mode 100644 index 0000000..05de045 --- /dev/null +++ b/test/memory.test.js @@ -0,0 +1,69 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { describe, it } = require('node:test'); +const { InMemoryAdapter, createMemoryAdapter } = require('../src/memory'); + +describe('InMemoryAdapter', () => { + it('stores and retrieves values', async () => { + const mem = new InMemoryAdapter(); + await mem.set('key1', { data: 'hello' }); + const val = await mem.get('key1'); + assert.deepEqual(val, { data: 'hello' }); + }); + + it('returns null for missing keys', async () => { + const mem = new InMemoryAdapter(); + assert.equal(await mem.get('missing'), null); + }); + + it('deletes keys', async () => { + const mem = new InMemoryAdapter(); + await mem.set('k', 'v'); + assert.ok(await mem.has('k')); + await mem.delete('k'); + assert.ok(!(await mem.has('k'))); + }); + + it('supports TTL expiry', async () => { + const mem = new InMemoryAdapter(); + await mem.set('temp', 'val', 50); + assert.equal(await mem.get('temp'), 'val'); + await new Promise(r => setTimeout(r, 100)); + assert.equal(await mem.get('temp'), null); + }); + + it('lists keys with pattern', async () => { + const mem = new InMemoryAdapter(); + await mem.set('user:1', 'a'); + await mem.set('user:2', 'b'); + await mem.set('session:1', 'c'); + const userKeys = await mem.keys('user:*'); + assert.equal(userKeys.length, 2); + assert.ok(userKeys.includes('user:1')); + assert.ok(userKeys.includes('user:2')); + }); + + it('clears all data', async () => { + const mem = new InMemoryAdapter(); + await mem.set('a', 1); + await mem.set('b', 2); + await mem.clear(); + assert.equal(mem.size(), 0); + }); +}); + +describe('createMemoryAdapter', () => { + it('creates in-memory adapter', () => { + const adapter = createMemoryAdapter('in-memory'); + assert.ok(adapter instanceof InMemoryAdapter); + }); + + it('throws for redis without package', () => { + assert.throws(() => createMemoryAdapter('redis'), /Redis adapter/); + }); + + it('throws for unknown adapter', () => { + assert.throws(() => createMemoryAdapter('unknown'), /Unknown memory adapter/); + }); +});