From 22ca428ef1e5fc0668de18030b061127e13fab5b Mon Sep 17 00:00:00 2001 From: Rahul Rajaram Date: Wed, 15 Apr 2026 22:43:31 -0400 Subject: [PATCH] feat: session-aware architecture with HTTP transport Replace in-memory agent identity (_agentName) with Redis-backed session records so identity survives process boundaries. Agents receive a session_id on registration and pass it to session-scoped tools, enabling cross-process reconnection for stateless and bridge-based transports. Also adds a native HTTP transport so bridges like supergateway are no longer needed. - Extract MailboxStore and SessionStore from RedisClient into src/core/ - Add TTL-based session leases for presence detection - Return session_id from register_agent - Accept optional session_id on send_message, receive_message, close_session, and unregister_agent via ensureSessionBinding helper - Add close_session tool (preserves mailbox) alongside destructive unregister_agent - Add native HTTP transport via StreamableHTTPServerTransport - Replace PTY wrapper polling with Redis keyspace notifications - Add vitest test suite (24 tests) and CI test job - Enable noUncheckedIndexedAccess in tsconfig --- .github/workflows/ci.yml | 34 + README.md | 134 +- bin/gptqueue-http | 2 + docs/MIGRATION.md | 81 ++ docs/README.md | 3 + docs/SESSION_TRANSPORT_REDESIGN_REPORT.md | 377 ++++++ package-lock.json | 1434 ++++++++++++++++++++- package.json | 10 +- src/core/keys.ts | 44 + src/core/mailbox-store.ts | 156 +++ src/core/session-store.ts | 208 +++ src/core/types.ts | 38 + src/mcp-server/index.ts | 59 +- src/mcp-server/redis-client.ts | 200 +-- src/mcp-server/tools/close-session.ts | 36 + src/mcp-server/tools/receive-message.ts | 8 + src/mcp-server/tools/register-agent.ts | 5 +- src/mcp-server/tools/send-message.ts | 9 + src/mcp-server/tools/session-binding.ts | 25 + src/mcp-server/tools/unregister-agent.ts | 20 +- src/pty-wrapper/index.ts | 9 +- src/pty-wrapper/redis-watcher.ts | 77 +- src/transports/http.ts | 132 ++ src/transports/setup-tools.ts | 74 ++ tests/cross-process-bug.test.ts | 132 ++ tests/http-transport.test.ts | 188 +++ tests/multi-session-policy.test.ts | 100 ++ tests/redis-client.test.ts | 122 ++ tests/session-scoped-tools.test.ts | 103 ++ tests/session-store.test.ts | 134 ++ tests/tool-contract.test.ts | 133 ++ tsconfig.json | 1 + vitest.config.ts | 10 + 33 files changed, 3856 insertions(+), 242 deletions(-) create mode 100755 bin/gptqueue-http create mode 100644 docs/MIGRATION.md create mode 100644 docs/README.md create mode 100644 docs/SESSION_TRANSPORT_REDESIGN_REPORT.md create mode 100644 src/core/keys.ts create mode 100644 src/core/mailbox-store.ts create mode 100644 src/core/session-store.ts create mode 100644 src/core/types.ts create mode 100644 src/mcp-server/tools/close-session.ts create mode 100644 src/mcp-server/tools/session-binding.ts create mode 100644 src/transports/http.ts create mode 100644 src/transports/setup-tools.ts create mode 100644 tests/cross-process-bug.test.ts create mode 100644 tests/http-transport.test.ts create mode 100644 tests/multi-session-policy.test.ts create mode 100644 tests/redis-client.test.ts create mode 100644 tests/session-scoped-tools.test.ts create mode 100644 tests/session-store.test.ts create mode 100644 tests/tool-contract.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c19eea..df7215e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,3 +31,37 @@ jobs: - name: Lint run: npx oxlint src/ + + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + services: + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install --ignore-scripts + + - name: Build + run: npm run build + + - name: Run tests + run: npm test + env: + REDIS_URL: redis://127.0.0.1:6379 diff --git a/README.md b/README.md index eb07f8f..b82feed 100644 --- a/README.md +++ b/README.md @@ -13,35 +13,45 @@ gptqueue lets AI agents (Claude Code, Codex, Gemini CLI, or any MCP-compatible c ## Architecture ``` -┌─────────────┐ MCP (stdio) ┌──────────────┐ -│ AI Agent A │◄──────────────────────►│ │ -└─────────────┘ │ gptqueue │ ┌───────┐ - │ MCP server │◄─────►│ Redis │ -┌─────────────┐ MCP (stdio) │ │ └───────┘ -│ AI Agent B │◄──────────────────────►│ │ -└─────────────┘ └──────────────┘ + MCP (stdio) +┌─────────────┐ ◄──────────────────────► ┌──────────────┐ +│ AI Agent A │ │ │ +└─────────────┘ │ gptqueue │ + MCP (HTTP) │ MCP server │◄─────► Redis +┌─────────────┐ ◄──────────────────────► │ │ +│ AI Agent B │ │ (sessions) │ +└─────────────┘ └──────────────┘ ``` -Each agent gets its own bounded inbox queue in Redis. Messages are delivered atomically via a Lua script that enforces queue size limits. Agents publish heartbeats so others can see who is online. +Each agent gets its own bounded inbox queue in Redis. Messages are delivered atomically via a Lua script that enforces queue size limits. + +Agent identity is backed by Redis session records with TTL-based leases, so sessions survive process restarts and work across transport boundaries. ## Components | Component | Description | |---|---| -| **MCP server** (`src/mcp-server/`) | Stdio-based MCP server exposing 6 tools for agent communication | +| **MCP server** (`src/mcp-server/`) | MCP server exposing tools for agent communication | +| **HTTP transport** (`src/transports/http.ts`) | Streamable HTTP server -- no bridge needed for shared hosting | +| **Core** (`src/core/`) | Transport-agnostic session store, mailbox store, and type definitions | | **PTY wrapper** (`src/pty-wrapper/`) | Wraps a CLI process in a PTY, watches Redis for incoming messages, and injects notifications when the process is idle | | **Hook script** (`scripts/check-queue.sh`) | Claude Code hook for startup context injection and stop-gate (blocks exit if inbox has unread messages) | +## Design Reports + +- [Session and Transport Redesign Report](docs/SESSION_TRANSPORT_REDESIGN_REPORT.md) + ## MCP Tools | Tool | Description | |---|---| -| `register_agent` | Register with a name, role (`publisher`/`consumer`/`both`), and description | -| `send_message` | Send a typed message (`task`/`result`/`status`/`error`/`ping`) to another agent's inbox. Supports optional `metadata` object and `in_reply_to` message ID for threading | -| `receive_message` | Blocking pop from your inbox (default timeout: 5s) | +| `register_agent` | Register with a name, role, and description. Returns a `session_id` for session resumption | +| `send_message` | Send a typed message (`task`/`result`/`status`/`error`/`ping`) to another agent's inbox. Supports optional `metadata`, `in_reply_to`, and `session_id` for stateless transports | +| `receive_message` | Blocking pop from your inbox (default timeout: 5s). Supports optional `session_id` for stateless transports | | `list_agents` | Discover all registered agents with online/offline status | | `get_queue_status` | Check queue depth and capacity for one or all agents | -| `unregister_agent` | Unregister and clean up queue data | +| `close_session` | Close the current session but preserve the mailbox. Messages remain queued for later reconnection. Supports optional `session_id` for stateless transports | +| `unregister_agent` | Unregister and delete all queue data (destructive). Supports optional `session_id` for stateless transports | ## Prerequisites @@ -56,9 +66,33 @@ cd gptqueue npm install # builds automatically via postinstall ``` +## Transports + +### Stdio (direct, single-client) + +For local use with one MCP client: + +```bash +node /path/to/gptqueue/dist/mcp-server/index.js [agent-name] +``` + +Or set `GPTQ_AGENT_NAME` in the environment. + +### HTTP (shared, multi-client) + +For shared hosting without bridges like supergateway: + +```bash +node /path/to/gptqueue/dist/transports/http.js --port 3001 +``` + +Each connecting client gets its own MCP session backed by Redis. Sessions survive reconnects. + +Health check: `GET /health` returns `{"status":"ok","sessions":N}`. + ## Configuration -### Claude Code +### Claude Code (stdio) Add to `~/.claude.json` under `mcpServers`: @@ -72,6 +106,26 @@ Add to `~/.claude.json` under `mcpServers`: } ``` +### Claude Code (HTTP) + +Start the HTTP server, then configure the client to connect: + +```bash +# Start the server +node /path/to/gptqueue/dist/transports/http.js --port 3001 +``` + +```json +{ + "gptqueue": { + "type": "streamable-http", + "url": "http://127.0.0.1:3001/mcp" + } +} +``` + +### Hook script + Optionally add the hook script to `~/.claude/settings.json` for automatic startup context and exit gating: ```json @@ -95,23 +149,14 @@ Optionally add the hook script to `~/.claude/settings.json` for automatic startu } ``` -### Any MCP client - -The server speaks stdio MCP. Point your client at: - -```bash -node /path/to/gptqueue/dist/mcp-server/index.js [agent-name] -``` - -Or set `GPTQ_AGENT_NAME` in the environment. - ### Environment variables | Variable | Default | Description | |---|---|---| -| `REDIS_URL` | `redis://127.0.0.1:6379` | Redis connection URL (MCP server and PTY wrapper) | -| `GPTQ_AGENT_NAME` | _(none)_ | Pre-register with this agent name on startup | +| `REDIS_URL` | `redis://127.0.0.1:6379` | Redis connection URL | +| `GPTQ_AGENT_NAME` | _(none)_ | Pre-register with this agent name on startup (stdio only) | | `GPTQ_QUEUE_BOUND` | `10` | Max messages per agent inbox | +| `GPTQ_HTTP_PORT` | `3001` | HTTP server port | | `REDIS_HOST` | `127.0.0.1` | Redis host (hook script only) | | `REDIS_PORT` | `6379` | Redis port (hook script only) | @@ -125,15 +170,42 @@ gptqueue-pty --agent alice --cmd claude ## How it works -1. **Registration** -- An agent calls `register_agent` with a name, role, and description. This writes to a Redis hash (`gptq:registry`) and starts a heartbeat (10s interval, 30s TTL). +1. **Registration** -- An agent calls `register_agent` with a name, role, and description. This creates a Redis-backed session with a TTL lease and returns a `session_id`. + +2. **Sessions** -- Each registration creates a session record in Redis. Sessions have TTL-based leases that are automatically refreshed. An agent can have multiple concurrent sessions (e.g. from different transports). + +3. **Discovery** -- Any agent (even unregistered) can call `list_agents` to see all registered agents and whether they're online. Online status is computed from active session leases. + +4. **Messaging** -- `send_message` pushes to the target agent's Redis list (`gptq:q:`). A Lua script enforces the queue bound atomically. If the queue is full, the sender retries with exponential backoff (up to 10 attempts). + +5. **Receiving** -- `receive_message` does a blocking pop (`BLPOP`) with a configurable timeout. + +6. **Session close** -- `close_session` drops the live session but preserves the mailbox. Queued messages remain available for a future session. + +7. **Unregister** -- `unregister_agent` closes the session AND deletes the mailbox. This is a destructive operation. -2. **Discovery** -- Any agent (even unregistered) can call `list_agents` to see all registered agents and whether they're online. +## Known Limitations -3. **Messaging** -- `send_message` pushes to the target agent's Redis list (`gptq:q:`). A Lua script enforces the queue bound atomically. If the queue is full, the sender retries with exponential backoff (up to 10 attempts). +### Bridge-based transport (supergateway) -4. **Receiving** -- `receive_message` does a blocking pop (`BLPOP`) with a configurable timeout. +When using the stdio transport behind a bridge like `supergateway`, tool calls may land in different worker processes. The recommended solution is to use the native HTTP transport instead, which eliminates the need for bridges entirely. -5. **Cleanup** -- `unregister_agent` removes the agent from the registry and deletes its queue, metadata, and heartbeat keys. +If you must use a stateless or bridge-based transport, capture the +`session_id` returned by `register_agent` and pass it to session-scoped +tools such as `send_message`, `receive_message`, `close_session`, and +`unregister_agent`. + +### Child-process accumulation + +If still using bridges, they can accumulate orphaned gptqueue worker processes. To check and clean up: + +```bash +# Count gptqueue child workers +pgrep -f 'gptqueue' | wc -l + +# Kill orphaned workers (use with caution) +pkill -f 'dist/mcp-server/index.js' +``` ## License diff --git a/bin/gptqueue-http b/bin/gptqueue-http new file mode 100755 index 0000000..2ed6f40 --- /dev/null +++ b/bin/gptqueue-http @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import "../dist/transports/http.js"; diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..2db9c2d --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,81 @@ +# Migration Guide: Session-Aware gptqueue + +This document covers changes from the original single-process stdio design to the session-aware architecture. + +## What Changed + +### Registration returns a session_id + +**Before:** +```json +{ "status": "registered", "name": "my-agent", "role": "both" } +``` + +**After:** +```json +{ "status": "registered", "name": "my-agent", "session_id": "abc-123", "role": "both" } +``` + +The `session_id` can be used to reconnect from a different process without re-registering. + +For stateless or bridge-based transports, pass that `session_id` to +session-scoped tools: + +- `send_message(session_id?, to, ...)` +- `receive_message(session_id?, timeout)` +- `close_session(session_id?)` +- `unregister_agent(session_id?)` + +### New tool: close_session + +`close_session` drops the live session but **preserves** the mailbox. Queued messages remain available for a future session. + +`unregister_agent` remains available and is **destructive** -- it deletes the mailbox and all queue data. + +### Online status from session leases + +Online/offline status is now computed from session leases (TTL-based keys in Redis) rather than a single heartbeat key. An agent with multiple sessions is online if any session has a live lease. + +### Native HTTP transport + +A new HTTP transport eliminates the need for `supergateway` or other stdio bridges: + +```bash +node dist/transports/http.js --port 3001 +``` + +## Redis Key Changes + +### New keys + +| Key | Type | Purpose | +|---|---|---| +| `gptq:session:` | Hash | Session record (agent_name, role, timestamps) | +| `gptq:lease:` | String with TTL | Session liveness indicator | +| `gptq:agent-sessions:` | Set | Active session IDs for an agent | + +### Preserved keys + +| Key | Type | Purpose | +|---|---|---| +| `gptq:registry` | Hash | Agent discovery index (unchanged) | +| `gptq:q:` | List | Mailbox queue (unchanged) | +| `gptq:meta:` | Hash | Queue metadata (unchanged) | +| `gptq:heartbeat:` | String with TTL | Legacy heartbeat (kept for backward compat) | + +## Backward Compatibility + +- Existing stdio clients continue to work without changes +- `GPTQ_AGENT_NAME` auto-registration still works (now creates a session automatically) +- Legacy heartbeat keys are still written alongside session leases +- `list_agents` checks both session leases and legacy heartbeats +- `unregister_agent` still performs the same destructive cleanup + +## Upgrading + +1. Update your gptqueue installation: `git pull && npm install` +2. No Redis data migration needed -- new keys are additive +3. To use HTTP transport, start the HTTP server and update your MCP client config +4. To use stateless or bridge-friendly session reconnection, capture + `session_id` from `register_agent` responses and pass it to + session-scoped tools diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..5a37cb7 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,3 @@ +# gptqueue Docs + +- [Session and Transport Redesign Report](./SESSION_TRANSPORT_REDESIGN_REPORT.md) diff --git a/docs/SESSION_TRANSPORT_REDESIGN_REPORT.md b/docs/SESSION_TRANSPORT_REDESIGN_REPORT.md new file mode 100644 index 0000000..5dcb87a --- /dev/null +++ b/docs/SESSION_TRANSPORT_REDESIGN_REPORT.md @@ -0,0 +1,377 @@ +# gptqueue Session and Transport Redesign Report + +Date: 2026-04-15 + +## Executive Summary + +This report documents a recommended architectural redesign for `gptqueue` +after field evidence showed that the current process model does not hold up +under bridge-based MCP usage. The present implementation assumes a long-lived +single-process stdio server with in-memory agent identity. In practice, the +package is also being used behind transport bridges such as `supergateway`, +where tool calls may not be served by one stable process. That mismatch can +produce two severe failures: + +1. transport/session drift, where `register_agent` appears to succeed but + later calls such as `receive_message` and `send_message` fail with + "Agent not registered" +2. child-process accumulation, where large numbers of MCP helper workers are + left behind and consume substantial host memory + +The core recommendation is to make `gptqueue` a Redis-backed mailbox and +session service with thin transport adapters. Agent identity and online state +should no longer depend on one process holding mutable in-memory state. + +## Background + +`gptqueue` exists to let agents discover each other and exchange messages over +MCP with Redis as the durable queue backend. Today the repository presents +three main elements: + +- `src/mcp-server/`: the MCP server +- `src/pty-wrapper/`: a PTY wrapper that runs a CLI and nudges it when inbox + messages arrive +- Redis queue and registry keys for message delivery and online/offline status + +The current README describes the architecture as a stdio MCP server connected +to Redis. That shape is valid for a direct one-client-to-one-process setup. +The trouble begins when the package is used through a transport bridge that +proxies or respawns stdio workers. + +## Current Implementation Shape + +The current server wiring creates a single `RedisClient` instance at process +startup: + +- `src/mcp-server/index.ts` + +That `RedisClient` stores mutable agent identity in `_agentName`: + +- `src/mcp-server/redis-client.ts` + +Tool behavior then depends on that in-memory field: + +- `register_agent` updates `_agentName` +- `send_message` and `receive_message` call `requireRegistered()` +- heartbeat is driven by a process-local `setInterval` +- `unregister_agent` deletes queue and metadata and clears `_agentName` + +This design assumes: + +1. one MCP session maps to one server process +2. the process remains alive across all tool calls +3. heartbeats are tied to process lifetime +4. queue ownership is the same thing as connection ownership + +Those assumptions are too strong for bridge-based or multi-transport usage. + +## Field Evidence That Triggered This Report + +During downstream use on 2026-04-15, two distinct symptoms were observed: + +### 1. Registration worked, but later inbox operations failed + +`list_agents` and queue depth lookups showed the agent as online, but +`receive_message` and `send_message` still returned: + +`Agent not registered. Call register_agent first with a name.` + +That symptom strongly suggests the registration was durable in Redis, while +the later tool call landed in a different process that did not hold the same +in-memory `_agentName`. + +### 2. Extreme child-process accumulation + +Observed on the host: + +- about 985 stale `gptqueue` child node workers consuming about 24.2 GiB RSS +- about 480 stale Perplexity MCP child workers consuming about 13.6 GiB RSS +- only two long-lived `supergateway` root processes above them + +After clearing the orphaned child workers, host memory improved from roughly +1.7 GiB free to about 47 GiB free. That was an operational win, but it also +demonstrated that the transport/process model is currently too leak-prone to +be trusted as-is. + +## Root Cause Analysis + +The main design problem is not Redis. It is identity ownership. + +`gptqueue` currently mixes three concerns inside one mutable process object: + +1. queue operations +2. transport lifecycle +3. agent session identity + +That coupling is acceptable for direct stdio with one long-lived process, but +it breaks down under any of the following: + +- bridge/proxy transports +- worker respawn behavior +- HTTP-style request handling +- multiple clients sharing one service endpoint +- process restarts while mailboxes should remain valid + +The most important conceptual error is this: + +`agent registered` is treated as `this process remembers an agent name` + +but the system actually needs: + +`agent/session registered` to be durable and transport-independent + +## Design Goals + +The redesign should satisfy the following: + +1. Agent identity survives transport process churn. +2. Mailboxes survive disconnects and reconnects. +3. Online/offline state is lease-based, not bound to one local timer in one + worker. +4. Direct stdio remains easy for simple local usage. +5. HTTP and bridge-based usage become first-class instead of accidental. +6. PTY notification behavior remains available without owning message-state + semantics. +7. The server becomes testable in transport and session scenarios. + +## Recommended Architecture + +### A. Introduce a Redis-backed session layer + +Add an explicit session abstraction instead of keeping `_agentName` as the +authoritative registration state in process memory. + +Suggested concepts: + +- mailbox: durable queue for a named agent +- session: a live lease representing one connected transport/client +- lease: TTL-backed online presence record + +Suggested Redis model: + +- `gptq:agent:`: canonical agent metadata +- `gptq:queue:`: durable mailbox +- `gptq:session:`: session record containing `agent`, `role`, + `description`, `last_seen`, optional transport metadata +- `gptq:lease:`: TTL-backed liveness indicator +- `gptq:agent-sessions:`: set of active session ids + +With that model: + +- a process can reconnect and resume a session or create a new one +- an agent can be online via one or more sessions +- queue ownership is decoupled from one worker's heap + +### B. Make tool calls explicit about session or agent context + +There are two viable approaches: + +#### Option 1: session token on every session-scoped tool + +Examples: + +- `register_agent -> { session_id, agent_name }` +- `send_message(session_id, to, ...)` +- `receive_message(session_id, timeout)` +- `unregister_session(session_id)` + +This is the cleanest model under stateless transports. + +#### Option 2: transport-managed session binding + +If using a native stateful transport, bind one session to one transport +instance and keep the session id in transport state, not in the mailbox core. + +This can make client ergonomics nicer, but the server core should still be +written as if session id were explicit and durable. + +Recommendation: implement the core as explicit-session-first, then allow the +stdio adapter to hide the session token for convenience. + +### C. Promote native streamable HTTP to a first-class transport + +The installed MCP SDK already includes server-side streamable HTTP transport. +That means `gptqueue` does not need to rely on `supergateway --stdio ...` for +shared or bridge-based hosting. + +Recommendation: + +- keep stdio support for local direct-client usage +- add a native streamable HTTP server entrypoint +- document stdio as the simple adapter, not the only real architecture + +This reduces bridge complexity and makes the transport topology honest. + +### D. Separate queue-core from transport adapters + +Split the code into layers: + +1. `core/queue-store` + - Redis operations + - registration/session persistence + - send/receive primitives + - lease refresh + +2. `core/session-policy` + - mailbox/session rules + - online/offline computation + - rename/migration behavior + +3. `transports/stdio` + - MCP stdio adapter + - convenience binding to one session + +4. `transports/http` + - streamable HTTP adapter + - stateful or stateless MCP session management + +5. `pty-wrapper` + - remains a separate UX tool + - only observes queue state and nudges the user + - does not own registration semantics + +### E. Redefine unregister behavior + +Today `unregister_agent` deletes queue data entirely. That is too destructive +for a mailbox abstraction. + +Recommendation: + +- split "close my live session" from "delete this mailbox" +- make mailbox deletion an explicit administrative action +- default disconnect behavior should preserve messages and metadata + +This is especially important when transports are flaky or ephemeral. + +## Why This Change Is Worth Doing + +### Reliability + +The redesign removes the observed class of "registered, then not registered" +failures because the truth moves from process memory into Redis-backed session +state. + +### Operational Stability + +A transport that can safely respawn or reconnect without multiplying hidden +workers will materially reduce host memory risk. + +### Architectural Honesty + +The current implementation is advertised as an inter-agent queue, but the +actual operational contract is "a single lucky stdio worker that must stay +alive." The redesign aligns implementation with product intent. + +### Easier Multi-Client and Future Integrations + +Once the core is session-aware and transport-agnostic, adding HTTP hosting, +browser tools, daemonized operation, or richer observability gets much easier. + +## Proposed Implementation Plan + +### Phase 0: Immediate Safeguards + +1. Document that bridge-based usage is risky under the current design. +2. Add a troubleshooting note for child-process buildup. +3. Add tests reproducing the current failure mode with separate server + instances. + +### Phase 1: Session Core Extraction + +1. Extract Redis queue operations from `RedisClient` into a mailbox/core + module. +2. Introduce explicit session records and lease refresh operations. +3. Replace `_agentName` as the authoritative source of registration state. + +### Phase 2: Tool Contract Redesign + +1. Return `session_id` from registration. +2. Make `send_message`, `receive_message`, and session-scoped operations accept + a session id or operate through a transport-bound session context. +3. Introduce `close_session` separate from mailbox deletion. + +### Phase 3: Native HTTP Transport + +1. Add a streamable HTTP server entrypoint. +2. Keep stdio entrypoint as a thin adapter. +3. Stop treating `supergateway` as the default path for shared hosting. + +### Phase 4: Presence and Mailbox Semantics + +1. Convert online/offline to session lease aggregation. +2. Decide policy for multiple sessions per agent name. +3. Preserve queues across disconnects. + +### Phase 5: Cleanup and Compatibility + +1. Provide compatibility wrappers for older stdio clients. +2. Update README and examples. +3. Add migration notes. + +## Suggested Redis API Direction + +These are illustrative, not final: + +- `register_session(agent_name, role, description) -> session_id` +- `refresh_session(session_id)` +- `close_session(session_id)` +- `delete_mailbox(agent_name)` admin-only +- `send_message(session_id, to, ...)` +- `receive_message(session_id, timeout)` +- `list_agents()` computed from agent metadata plus live leases +- `get_queue_status(agent_name?)` + +## Test Plan + +The redesign should come with tests that cover cases missing today: + +1. registration in one process followed by send/receive in another process +2. session reconnect after process restart +3. heartbeat/lease expiry marks agent offline without deleting mailbox +4. multiple sessions for one agent +5. HTTP transport request sequence across distinct worker instances +6. stdio compatibility path with a stable single process +7. unregister session vs delete mailbox semantics + +## Risks and Tradeoffs + +### More explicit state + +The design becomes more formal. That is good, but it means slightly more +surface area and some migration work. + +### Client ergonomics + +Explicit session ids are slightly less magical. The stdio adapter can hide +some of that, but the core should remain explicit. + +### Backward Compatibility + +Existing clients may assume registration is a one-time in-process effect. A +compatibility layer or migration window will help. + +## Recommended First Implementation Slice + +If only one architecture slice is taken next, it should be this: + +1. introduce Redis-backed session records +2. return a `session_id` from registration +3. stop using `_agentName` as the only truth for later calls + +That slice directly addresses the correctness bug even before HTTP transport is +added. + +If a second slice is available, make it: + +4. add native streamable HTTP transport + +That slice removes the need for a bridge that can hide or multiply stdio +workers. + +## Conclusion + +`gptqueue` should evolve from a clever stdio-local MCP helper into a real +message service with durable mailbox semantics and explicit session ownership. +The current design is close enough to be useful, but not robust enough for the +way it is already being used. The field evidence is strong enough that this +should be treated as an architectural follow-up, not a minor bugfix. diff --git a/package-lock.json b/package-lock.json index 0ce99d5..325c147 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.2.3", "@types/uuid": "^10.0.0", + "express": "^5.2.1", "ioredis": "^5.9.3", "node-pty": "^1.1.0", "typescript": "^5.9.3", @@ -24,7 +25,46 @@ "gptqueue-server": "bin/gptqueue-server" }, "devDependencies": { - "oxlint": "^1.51.0" + "@types/express": "^5.0.6", + "oxlint": "^1.51.0", + "vitest": "^4.1.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@hono/node-server": { @@ -45,6 +85,13 @@ "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", "license": "MIT" }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -85,6 +132,35 @@ } } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@oxlint/binding-android-arm-eabi": { "version": "1.51.0", "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.51.0.tgz", @@ -408,21 +484,529 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.2.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "license": "MIT" - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -469,6 +1053,16 @@ } } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -531,6 +1125,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -562,6 +1166,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -646,6 +1257,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -693,6 +1314,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -711,6 +1339,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -741,6 +1379,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -824,6 +1472,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -863,6 +1529,21 @@ "node": ">= 0.8" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1026,51 +1707,312 @@ "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", "license": "MIT", "engines": { - "node": ">= 12" + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", + "node": ">= 12.0.0" + }, "funding": { - "url": "https://github.com/sponsors/panva" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -1083,6 +2025,16 @@ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1144,6 +2096,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -1190,6 +2161,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1284,6 +2266,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -1293,6 +2302,35 @@ "node": ">=16.20.0" } }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1375,6 +2413,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1541,6 +2613,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -1556,6 +2652,57 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1565,6 +2712,14 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -1629,6 +2784,174 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1644,6 +2967,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 3930b7f..daa76b2 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,16 @@ "main": "dist/mcp-server/index.js", "bin": { "gptqueue-server": "./bin/gptqueue-server", + "gptqueue-http": "./bin/gptqueue-http", "gptqueue-pty": "./bin/gptqueue-pty" }, "scripts": { "build": "tsc && cp -r src/mcp-server/lua dist/mcp-server/", "prepare": "bash -c 'COMMITHOOKS=${COMMITHOOKS_DIR:-$HOME/Documents/commithooks}; GIT_DIR=$(git rev-parse --git-dir 2>/dev/null); [ -d \"$COMMITHOOKS/lib\" ] && [ -n \"$GIT_DIR\" ] && for h in pre-commit commit-msg pre-push post-checkout post-merge; do [ -f \"$COMMITHOOKS/$h\" ] && cp \"$COMMITHOOKS/$h\" \"$GIT_DIR/hooks/$h\" && chmod +x \"$GIT_DIR/hooks/$h\"; done && rm -rf \"${GIT_DIR}/lib\" && cp -r \"$COMMITHOOKS/lib\" \"$GIT_DIR/lib\" || true'", "postinstall": "npm run build", - "dev": "tsc --watch" + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest" }, "engines": { "node": ">=18" @@ -22,6 +25,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.2.3", "@types/uuid": "^10.0.0", + "express": "^5.2.1", "ioredis": "^5.9.3", "node-pty": "^1.1.0", "typescript": "^5.9.3", @@ -29,6 +33,8 @@ "zod": "^4.3.6" }, "devDependencies": { - "oxlint": "^1.51.0" + "@types/express": "^5.0.6", + "oxlint": "^1.51.0", + "vitest": "^4.1.4" } } diff --git a/src/core/keys.ts b/src/core/keys.ts new file mode 100644 index 0000000..975a3c1 --- /dev/null +++ b/src/core/keys.ts @@ -0,0 +1,44 @@ +/** + * Redis key schema for sessions, mailboxes, and leases. + * + * New session-aware keys coexist with legacy keys during migration. + */ + +/** Legacy keys (kept for backward compatibility during transition). */ +export const LEGACY_KEYS = { + registry: "gptq:registry", + queue: (agent: string) => `gptq:q:${agent}`, + meta: (agent: string) => `gptq:meta:${agent}`, + heartbeat: (agent: string) => `gptq:heartbeat:${agent}`, +} as const; + +/** Session-aware key schema. */ +export const SESSION_KEYS = { + /** Canonical agent metadata (hash). */ + agent: (name: string) => `gptq:agent:${name}`, + + /** Durable mailbox queue (list). */ + queue: (name: string) => `gptq:q:${name}`, + + /** Mailbox metadata (hash): max_size, created_at, current_size. */ + mailboxMeta: (name: string) => `gptq:meta:${name}`, + + /** Session record (hash): agent_name, role, description, created_at, last_seen. */ + session: (sessionId: string) => `gptq:session:${sessionId}`, + + /** TTL-backed liveness indicator for a session (string with EX). */ + lease: (sessionId: string) => `gptq:lease:${sessionId}`, + + /** Set of active session IDs for an agent (set). */ + agentSessions: (name: string) => `gptq:agent-sessions:${name}`, + + /** Global agent discovery index (hash, same as legacy registry). */ + registry: "gptq:registry", +} as const; + +/** Default constants. */ +export const SESSION_DEFAULTS = { + LEASE_TTL_SECONDS: 30, + LEASE_REFRESH_INTERVAL_SECONDS: 10, + DEFAULT_QUEUE_BOUND: 10, +} as const; diff --git a/src/core/mailbox-store.ts b/src/core/mailbox-store.ts new file mode 100644 index 0000000..245c94f --- /dev/null +++ b/src/core/mailbox-store.ts @@ -0,0 +1,156 @@ +/** + * MailboxStore: pure queue operations backed by Redis. + * + * This module owns send, receive, depth, bounded-push, and queue status. + * It does NOT own agent identity, session state, or heartbeats. + */ + +import { Redis } from "ioredis"; +import { readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { SESSION_KEYS, SESSION_DEFAULTS } from "./keys.js"; +import type { QueueMessage } from "../mcp-server/types.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const LUA_DIR = join(__dirname, "..", "mcp-server", "lua"); + +export class MailboxStore { + private readonly redis: Redis; + private readonly subscriber: Redis; + private readonly boundedPushScript: string; + readonly queueBound: number; + + constructor(redis: Redis, subscriber: Redis, queueBound?: number) { + this.redis = redis; + this.subscriber = subscriber; + this.queueBound = + queueBound ?? + parseInt( + process.env.GPTQ_QUEUE_BOUND || + String(SESSION_DEFAULTS.DEFAULT_QUEUE_BOUND), + 10 + ); + + this.boundedPushScript = readFileSync( + join(LUA_DIR, "bounded-push.lua"), + "utf-8" + ); + } + + /** Push a message to a target agent's mailbox with bounded retry. */ + async send(message: QueueMessage): Promise { + const queueKey = SESSION_KEYS.queue(message.to); + const metaKey = SESSION_KEYS.mailboxMeta(message.to); + const serialized = JSON.stringify(message); + + let delay = 100; + const maxDelay = 5000; + const maxAttempts = 10; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const result = await this.redis.eval( + this.boundedPushScript, + 2, + queueKey, + metaKey, + serialized, + this.queueBound + ); + if (result === 1) return true; + + await new Promise((r) => setTimeout(r, delay)); + delay = Math.min(delay * 2, maxDelay); + } + return false; + } + + /** Blocking pop from an agent's mailbox. */ + async receive( + agentName: string, + timeout: number = 5 + ): Promise { + const result = await this.subscriber.blpop( + SESSION_KEYS.queue(agentName), + timeout + ); + if (!result) return null; + + const message: QueueMessage = JSON.parse(result[1]); + + const len = await this.redis.llen(SESSION_KEYS.queue(agentName)); + await this.redis.hset( + SESSION_KEYS.mailboxMeta(agentName), + "current_size", + len + ); + + return message; + } + + /** Get queue depth for a named agent. */ + async depth(agentName: string): Promise { + return this.redis.llen(SESSION_KEYS.queue(agentName)); + } + + /** Get queue status for one or all agents. */ + async status( + agentName?: string + ): Promise<{ agent: string; depth: number; max_size: number }[]> { + const targets = agentName + ? [agentName] + : Object.keys(await this.redis.hgetall(SESSION_KEYS.registry)); + + const statuses = []; + for (const name of targets) { + const d = await this.redis.llen(SESSION_KEYS.queue(name)); + const meta = await this.redis.hgetall(SESSION_KEYS.mailboxMeta(name)); + statuses.push({ + agent: name, + depth: d, + max_size: parseInt( + meta["max_size"] || + String(SESSION_DEFAULTS.DEFAULT_QUEUE_BOUND), + 10 + ), + }); + } + return statuses; + } + + /** Initialize mailbox metadata for an agent. */ + async ensureMailbox(agentName: string): Promise { + const metaKey = SESSION_KEYS.mailboxMeta(agentName); + const exists = await this.redis.exists(metaKey); + if (!exists) { + await this.redis.hset(metaKey, { + max_size: this.queueBound, + created_at: new Date().toISOString(), + }); + } + } + + /** Delete a mailbox entirely (admin action). */ + async deleteMailbox(agentName: string): Promise { + await this.redis.del( + SESSION_KEYS.queue(agentName), + SESSION_KEYS.mailboxMeta(agentName) + ); + } + + /** Migrate messages from one mailbox to another. */ + async migrateMessages( + fromAgent: string, + toAgent: string + ): Promise { + let count = 0; + let msg: string | null; + while ( + (msg = await this.redis.lpop(SESSION_KEYS.queue(fromAgent))) !== null + ) { + await this.redis.rpush(SESSION_KEYS.queue(toAgent), msg); + count++; + } + return count; + } +} diff --git a/src/core/session-store.ts b/src/core/session-store.ts new file mode 100644 index 0000000..4083d96 --- /dev/null +++ b/src/core/session-store.ts @@ -0,0 +1,208 @@ +/** + * SessionStore: Redis-backed session lifecycle management. + * + * Owns session creation, lease refresh, session close, and presence queries. + * Does NOT own queue/mailbox operations (see MailboxStore). + */ + +import { Redis } from "ioredis"; +import { v4 as uuidv4 } from "uuid"; +import { SESSION_KEYS, SESSION_DEFAULTS } from "./keys.js"; +import type { SessionRecord, LeaseState, AgentPresence } from "./types.js"; + +export class SessionStore { + private readonly redis: Redis; + private refreshTimer: ReturnType | null = null; + + constructor(redis: Redis) { + this.redis = redis; + } + + /** Create a new session for an agent. Returns the session record. */ + async createSession( + agentName: string, + role: "publisher" | "consumer" | "both", + description?: string, + transport?: string + ): Promise { + const sessionId = uuidv4(); + const now = new Date().toISOString(); + + const record: SessionRecord = { + session_id: sessionId, + agent_name: agentName, + role, + description, + created_at: now, + last_seen: now, + transport, + }; + + // Store session record + await this.redis.hset( + SESSION_KEYS.session(sessionId), + "session_id", sessionId, + "agent_name", agentName, + "role", role, + "description", description ?? "", + "created_at", now, + "last_seen", now, + "transport", transport ?? "" + ); + + // Register in agent's session set + await this.redis.sadd(SESSION_KEYS.agentSessions(agentName), sessionId); + + // Update agent metadata in registry + const agentMeta = { + name: agentName, + role, + description: description ?? undefined, + registered_at: now, + }; + await this.redis.hset( + SESSION_KEYS.registry, + agentName, + JSON.stringify(agentMeta) + ); + + // Set initial lease + await this.refreshLease(sessionId); + + return record; + } + + /** Refresh a session's lease TTL. */ + async refreshLease(sessionId: string): Promise { + await this.redis.set( + SESSION_KEYS.lease(sessionId), + "alive", + "EX", + SESSION_DEFAULTS.LEASE_TTL_SECONDS + ); + + // Update last_seen on the session record + await this.redis.hset( + SESSION_KEYS.session(sessionId), + "last_seen", + new Date().toISOString() + ); + } + + /** Start automatic lease refresh for a session. */ + startLeaseRefresh(sessionId: string): void { + this.stopLeaseRefresh(); + this.refreshLease(sessionId).catch(() => {}); + this.refreshTimer = setInterval( + () => this.refreshLease(sessionId).catch(() => {}), + SESSION_DEFAULTS.LEASE_REFRESH_INTERVAL_SECONDS * 1000 + ); + } + + /** Stop automatic lease refresh. */ + stopLeaseRefresh(): void { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = null; + } + } + + /** Close a session (preserves mailbox). */ + async closeSession(sessionId: string): Promise { + this.stopLeaseRefresh(); + + // Look up which agent this session belongs to + const agentName = await this.redis.hget( + SESSION_KEYS.session(sessionId), + "agent_name" + ); + if (!agentName) return null; + + // Remove session from agent's set + await this.redis.srem(SESSION_KEYS.agentSessions(agentName), sessionId); + + // Delete session record and lease + await this.redis.del( + SESSION_KEYS.session(sessionId), + SESSION_KEYS.lease(sessionId) + ); + + return agentName; + } + + /** Get a session record by ID. */ + async getSession(sessionId: string): Promise { + const data = await this.redis.hgetall(SESSION_KEYS.session(sessionId)); + if (!data.session_id) return null; + + return { + session_id: data.session_id!, + agent_name: data.agent_name ?? "", + role: (data.role ?? "both") as SessionRecord["role"], + description: data.description || undefined, + created_at: data.created_at ?? "", + last_seen: data.last_seen ?? "", + transport: data.transport || undefined, + }; + } + + /** Resolve agent name from a session ID (Redis lookup). */ + async resolveAgent(sessionId: string): Promise { + return this.redis.hget( + SESSION_KEYS.session(sessionId), + "agent_name" + ); + } + + /** Get lease state for a session. */ + async getLeaseState(sessionId: string): Promise { + const ttl = await this.redis.ttl(SESSION_KEYS.lease(sessionId)); + return { + session_id: sessionId, + alive: ttl > 0, + ttl_seconds: Math.max(ttl, 0), + }; + } + + /** Compute agent presence from session leases. */ + async getPresence(agentName: string): Promise { + const sessionIds = await this.redis.smembers( + SESSION_KEYS.agentSessions(agentName) + ); + + const activeSessions: string[] = []; + for (const sid of sessionIds) { + const lease = await this.getLeaseState(sid); + if (lease.alive) { + activeSessions.push(sid); + } else { + // Clean up expired session from the set + await this.redis.srem( + SESSION_KEYS.agentSessions(agentName), + sid + ); + await this.redis.del(SESSION_KEYS.session(sid)); + } + } + + return { + agent_name: agentName, + online: activeSessions.length > 0, + active_sessions: activeSessions, + }; + } + + /** List all sessions for an agent. */ + async listSessions(agentName: string): Promise { + const sessionIds = await this.redis.smembers( + SESSION_KEYS.agentSessions(agentName) + ); + + const sessions: SessionRecord[] = []; + for (const sid of sessionIds) { + const session = await this.getSession(sid); + if (session) sessions.push(session); + } + return sessions; + } +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..394433f --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,38 @@ +/** + * Session and Mailbox types for the transport-agnostic core. + * + * All interfaces are Readonly to encourage immutable usage. + * State transitions produce new objects via spread/copy, not mutation. + */ + +/** A live lease representing one connected transport/client. */ +export interface SessionRecord { + readonly session_id: string; + readonly agent_name: string; + readonly role: "publisher" | "consumer" | "both"; + readonly description?: string; + readonly created_at: string; + readonly last_seen: string; + readonly transport?: string; +} + +/** Canonical metadata for a named agent mailbox. */ +export interface MailboxMeta { + readonly agent_name: string; + readonly max_size: number; + readonly created_at: string; +} + +/** Lease state derived from TTL presence in Redis. */ +export interface LeaseState { + readonly session_id: string; + readonly alive: boolean; + readonly ttl_seconds: number; +} + +/** Agent presence computed from session lease aggregation. */ +export interface AgentPresence { + readonly agent_name: string; + readonly online: boolean; + readonly active_sessions: readonly string[]; +} diff --git a/src/mcp-server/index.ts b/src/mcp-server/index.ts index 9ac8834..825db23 100644 --- a/src/mcp-server/index.ts +++ b/src/mcp-server/index.ts @@ -3,69 +3,24 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { RedisClient } from "./redis-client.js"; -import { registerAgentSchema, registerAgent } from "./tools/register-agent.js"; -import { sendMessageSchema, sendMessage } from "./tools/send-message.js"; -import { - receiveMessageSchema, - receiveMessage, -} from "./tools/receive-message.js"; -import { listAgents } from "./tools/list-agents.js"; -import { queueStatusSchema, getQueueStatus } from "./tools/queue-status.js"; -import { unregisterAgent } from "./tools/unregister-agent.js"; +import { registerTools } from "../transports/setup-tools.js"; // Name comes from CLI arg or env var. If neither, starts unregistered. const initialName = process.argv[2] || process.env.GPTQ_AGENT_NAME || null; -const redisClient = new RedisClient(initialName); +const redisClient = new RedisClient(null); const server = new McpServer({ name: "gptqueue", version: "1.0.0", }); -server.tool( - "register_agent", - "Register this agent with a name, role, and description. The agent MUST ask the user to choose a name if one was not provided via GPTQ_AGENT_NAME. Can also be called again to rename midway -- pending messages are migrated.", - registerAgentSchema.shape, - async (params) => registerAgent(redisClient, registerAgentSchema.parse(params)) -); +registerTools(server, redisClient); -server.tool( - "send_message", - "Send a message to another agent's inbox queue. Retries with backoff if queue is full. Requires register_agent first.", - sendMessageSchema.shape, - async (params) => sendMessage(redisClient, sendMessageSchema.parse(params)) -); - -server.tool( - "receive_message", - "Receive the next message from this agent's inbox (blocking). Returns the message or timeout. Requires register_agent first.", - receiveMessageSchema.shape, - async (params) => - receiveMessage(redisClient, receiveMessageSchema.parse(params)) -); - -server.tool( - "list_agents", - "Discover other agents. Returns each agent's name, description (what they do), role, and online/offline status. Use this to find the right agent to send_message to. Works before registration.", - {}, - async () => listAgents(redisClient) -); - -server.tool( - "get_queue_status", - "Get queue depth and metadata for an agent (or all agents). Works before registration.", - queueStatusSchema.shape, - async (params) => - getQueueStatus(redisClient, queueStatusSchema.parse(params)) -); - -server.tool( - "unregister_agent", - "Unregister this agent and clean up its queue data", - {}, - async () => unregisterAgent(redisClient) -); +// Auto-register if name provided via CLI arg or env var (backward compat) +if (initialName) { + await redisClient.register("both", initialName); +} // Graceful shutdown async function shutdown() { diff --git a/src/mcp-server/redis-client.ts b/src/mcp-server/redis-client.ts index 912e182..45ddbd9 100644 --- a/src/mcp-server/redis-client.ts +++ b/src/mcp-server/redis-client.ts @@ -1,7 +1,4 @@ import { Redis } from "ioredis"; -import { readFileSync } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; import { REDIS_KEYS, HEARTBEAT_TTL, @@ -9,21 +6,27 @@ import { DEFAULT_QUEUE_BOUND, } from "./types.js"; import type { QueueMessage } from "./types.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); +import { MailboxStore } from "../core/mailbox-store.js"; +import { SessionStore } from "../core/session-store.js"; export class RedisClient { private redis: Redis; private subscriber: Redis; private heartbeatTimer: ReturnType | null = null; - private boundedPushScript: string; private _agentName: string | null; + private _sessionId: string | null = null; + private readonly mailbox: MailboxStore; + private readonly sessions: SessionStore; readonly queueBound: number; get agentName(): string | null { return this._agentName; } + get sessionId(): string | null { + return this._sessionId; + } + get registered(): boolean { return this._agentName !== null; } @@ -37,12 +40,8 @@ export class RedisClient { const url = redisUrl || process.env.REDIS_URL || "redis://127.0.0.1:6379"; this.redis = new Redis(url, { maxRetriesPerRequest: 3 }); this.subscriber = new Redis(url, { maxRetriesPerRequest: 3 }); - - // Load Lua script - this.boundedPushScript = readFileSync( - join(__dirname, "lua", "bounded-push.lua"), - "utf-8" - ); + this.mailbox = new MailboxStore(this.redis, this.subscriber, this.queueBound); + this.sessions = new SessionStore(this.redis); } requireRegistered(): string { @@ -54,23 +53,42 @@ export class RedisClient { return this._agentName; } + /** + * Reconnect to an existing session by session_id. + * Looks up the session in Redis and populates local state. + * This is the fix for the cross-process registration bug: + * a new process can resume a session without re-registering. + */ + async reconnectSession(sessionId: string): Promise { + const session = await this.sessions.getSession(sessionId); + if (!session) { + throw new Error(`Session ${sessionId} not found in Redis.`); + } + + this._sessionId = sessionId; + this._agentName = session.agent_name; + + // Refresh the lease to prove we're alive + this.sessions.startLeaseRefresh(sessionId); + + // Also maintain legacy heartbeat for backward compat + this.startHeartbeat(); + + return session.agent_name; + } + async register( role: string, name: string, description?: string - ): Promise { + ): Promise<{ name: string; session_id: string }> { const oldName = this._agentName; - if (oldName && name !== oldName) { - // Migrate pending messages from old queue to new queue - const oldQueue = REDIS_KEYS.queue(oldName); - const newQueue = REDIS_KEYS.queue(name); - let msg: string | null; - while ((msg = await this.redis.lpop(oldQueue)) !== null) { - await this.redis.rpush(newQueue, msg); - } + // Close previous session if renaming + if (this._sessionId && oldName && name !== oldName) { + await this.sessions.closeSession(this._sessionId); + await this.mailbox.migrateMessages(oldName, name); - // Clean up old keys await this.redis.hdel(REDIS_KEYS.registry, oldName); await this.redis.del( REDIS_KEYS.meta(oldName), @@ -82,8 +100,17 @@ export class RedisClient { } } + // Create a new session + const session = await this.sessions.createSession( + name, + role as "publisher" | "consumer" | "both", + description + ); + this._agentName = name; + this._sessionId = session.session_id; + // Legacy registry entry (for backward compat with old listAgents) const registration = { name, role, @@ -96,82 +123,81 @@ export class RedisClient { name, JSON.stringify(registration) ); - await this.redis.hset(REDIS_KEYS.meta(name), "max_size", this.queueBound); + await this.mailbox.ensureMailbox(name); + + // Start both session lease refresh and legacy heartbeat + this.sessions.startLeaseRefresh(session.session_id); this.startHeartbeat(); - return name; + + return { name, session_id: session.session_id }; } private startHeartbeat(): void { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); const name = this.requireRegistered(); const beat = async () => { - await this.redis.set( - REDIS_KEYS.heartbeat(name), - "alive", - "EX", - HEARTBEAT_TTL - ); + try { + await this.redis.set( + REDIS_KEYS.heartbeat(name), + "alive", + "EX", + HEARTBEAT_TTL + ); + } catch { + // Swallow errors from closed connections during shutdown + } }; beat(); this.heartbeatTimer = setInterval(beat, HEARTBEAT_INTERVAL * 1000); } + /** Close the current session but preserve the mailbox and registry entry. */ + async closeCurrentSession(): Promise { + const name = this.requireRegistered(); + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + this.sessions.stopLeaseRefresh(); + + if (this._sessionId) { + await this.sessions.closeSession(this._sessionId); + } + + // Remove heartbeat but keep registry and mailbox + await this.redis.del(REDIS_KEYS.heartbeat(name)); + + this._agentName = null; + this._sessionId = null; + return name; + } + + /** Full unregister: close session AND delete the mailbox (destructive). */ async unregister(): Promise { const name = this.requireRegistered(); if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + this.sessions.stopLeaseRefresh(); + + if (this._sessionId) { + await this.sessions.closeSession(this._sessionId); + } + + // Delete everything await this.redis.hdel(REDIS_KEYS.registry, name); - await this.redis.del( - REDIS_KEYS.queue(name), - REDIS_KEYS.meta(name), - REDIS_KEYS.heartbeat(name) - ); + await this.mailbox.deleteMailbox(name); + await this.redis.del(REDIS_KEYS.heartbeat(name)); + this._agentName = null; + this._sessionId = null; } async sendMessage(message: QueueMessage): Promise { this.requireRegistered(); - const queueKey = REDIS_KEYS.queue(message.to); - const metaKey = REDIS_KEYS.meta(message.to); - const serialized = JSON.stringify(message); - - // Try bounded push with exponential backoff - let delay = 100; - const maxDelay = 5000; - const maxAttempts = 10; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const result = await this.redis.eval( - this.boundedPushScript, - 2, - queueKey, - metaKey, - serialized, - this.queueBound - ); - if (result === 1) return true; - - // Queue full, backoff - await new Promise((r) => setTimeout(r, delay)); - delay = Math.min(delay * 2, maxDelay); - } - return false; + return this.mailbox.send(message); } async receiveMessage(timeout: number = 5): Promise { const name = this.requireRegistered(); - const result = await this.subscriber.blpop( - REDIS_KEYS.queue(name), - timeout - ); - if (!result) return null; - - const message: QueueMessage = JSON.parse(result[1]); - - // Update meta - const len = await this.redis.llen(REDIS_KEYS.queue(name)); - await this.redis.hset(REDIS_KEYS.meta(name), "current_size", len); - - return message; + return this.mailbox.receive(name, timeout); } async listAgents(): Promise< @@ -186,12 +212,14 @@ export class RedisClient { const agents = []; for (const [name, json] of Object.entries(registry)) { const reg = JSON.parse(json); - const heartbeat = await this.redis.get(REDIS_KEYS.heartbeat(name)); + // Prefer session-based presence; fall back to legacy heartbeat + const presence = await this.sessions.getPresence(name); + const legacyHeartbeat = await this.redis.get(REDIS_KEYS.heartbeat(name)); agents.push({ name, role: reg.role, description: reg.description, - online: heartbeat !== null, + online: presence.online || legacyHeartbeat !== null, }); } return agents; @@ -200,33 +228,17 @@ export class RedisClient { async getQueueStatus( agent?: string ): Promise<{ agent: string; depth: number; max_size: number }[]> { - const targets = agent - ? [agent] - : Object.keys(await this.redis.hgetall(REDIS_KEYS.registry)); - - const statuses = []; - for (const name of targets) { - const depth = await this.redis.llen(REDIS_KEYS.queue(name)); - const meta = await this.redis.hgetall(REDIS_KEYS.meta(name)); - statuses.push({ - agent: name, - depth, - max_size: parseInt( - meta["max_size"] || String(DEFAULT_QUEUE_BOUND), - 10 - ), - }); - } - return statuses; + return this.mailbox.status(agent); } async getQueueDepth(agent?: string): Promise { const name = agent || this.requireRegistered(); - return this.redis.llen(REDIS_KEYS.queue(name)); + return this.mailbox.depth(name); } async shutdown(): Promise { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + this.sessions.stopLeaseRefresh(); await this.redis.quit(); await this.subscriber.quit(); } diff --git a/src/mcp-server/tools/close-session.ts b/src/mcp-server/tools/close-session.ts new file mode 100644 index 0000000..93f746e --- /dev/null +++ b/src/mcp-server/tools/close-session.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import type { RedisClient } from "../redis-client.js"; +import { ensureSessionBinding } from "./session-binding.js"; + +export const closeSessionSchema = z.object({ + session_id: z + .string() + .optional() + .describe( + "Optional session_id returned by register_agent. Required when the transport does not preserve process-local registration state." + ), +}); + +export async function closeSession( + client: RedisClient, + params: z.infer +) { + await ensureSessionBinding(client, params.session_id); + const name = await client.closeCurrentSession(); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + status: "session_closed", + agent: name, + mailbox_preserved: true, + }, + null, + 2 + ), + }, + ], + }; +} diff --git a/src/mcp-server/tools/receive-message.ts b/src/mcp-server/tools/receive-message.ts index 32e8037..c90c63f 100644 --- a/src/mcp-server/tools/receive-message.ts +++ b/src/mcp-server/tools/receive-message.ts @@ -1,7 +1,14 @@ import { z } from "zod"; import type { RedisClient } from "../redis-client.js"; +import { ensureSessionBinding } from "./session-binding.js"; export const receiveMessageSchema = z.object({ + session_id: z + .string() + .optional() + .describe( + "Optional session_id returned by register_agent. Required when the transport does not preserve process-local registration state." + ), timeout: z .number() .default(5) @@ -12,6 +19,7 @@ export async function receiveMessage( client: RedisClient, params: z.infer ) { + await ensureSessionBinding(client, params.session_id); const message = await client.receiveMessage(params.timeout); if (message) { diff --git a/src/mcp-server/tools/register-agent.ts b/src/mcp-server/tools/register-agent.ts index 511407a..6c42852 100644 --- a/src/mcp-server/tools/register-agent.ts +++ b/src/mcp-server/tools/register-agent.ts @@ -21,7 +21,7 @@ export async function registerAgent( client: RedisClient, params: z.infer ) { - const resolvedName = await client.register( + const result = await client.register( params.role, params.name, params.description @@ -33,7 +33,8 @@ export async function registerAgent( text: JSON.stringify( { status: "registered", - name: resolvedName, + name: result.name, + session_id: result.session_id, role: params.role, description: params.description, }, diff --git a/src/mcp-server/tools/send-message.ts b/src/mcp-server/tools/send-message.ts index 27eea35..674849a 100644 --- a/src/mcp-server/tools/send-message.ts +++ b/src/mcp-server/tools/send-message.ts @@ -2,8 +2,15 @@ import { z } from "zod"; import { v4 as uuidv4 } from "uuid"; import type { RedisClient } from "../redis-client.js"; import type { QueueMessage } from "../types.js"; +import { ensureSessionBinding } from "./session-binding.js"; export const sendMessageSchema = z.object({ + session_id: z + .string() + .optional() + .describe( + "Optional session_id returned by register_agent. Required when the transport does not preserve process-local registration state." + ), to: z.string().describe("Target agent name"), content: z.string().describe("Message content"), type: z @@ -24,6 +31,8 @@ export async function sendMessage( client: RedisClient, params: z.infer ) { + await ensureSessionBinding(client, params.session_id); + const message: QueueMessage = { id: uuidv4(), from: client.requireRegistered(), diff --git a/src/mcp-server/tools/session-binding.ts b/src/mcp-server/tools/session-binding.ts new file mode 100644 index 0000000..829f279 --- /dev/null +++ b/src/mcp-server/tools/session-binding.ts @@ -0,0 +1,25 @@ +import type { RedisClient } from "../redis-client.js"; + +/** + * Ensure a session-scoped tool call is bound to a durable session. + * + * Stateful transports can keep agent identity in the RedisClient instance. + * Stateless or bridge-based transports may hand each tool call a fresh + * RedisClient with no in-memory registration state. In that case callers + * can pass the `session_id` returned by `register_agent` and we reconnect + * on demand before touching requireRegistered(). + */ +export async function ensureSessionBinding( + client: RedisClient, + sessionId?: string +): Promise { + if (!sessionId) { + return; + } + + if (client.sessionId === sessionId && client.registered) { + return; + } + + await client.reconnectSession(sessionId); +} diff --git a/src/mcp-server/tools/unregister-agent.ts b/src/mcp-server/tools/unregister-agent.ts index 21ac227..1c35af5 100644 --- a/src/mcp-server/tools/unregister-agent.ts +++ b/src/mcp-server/tools/unregister-agent.ts @@ -1,12 +1,28 @@ +import { z } from "zod"; import type { RedisClient } from "../redis-client.js"; +import { ensureSessionBinding } from "./session-binding.js"; -export async function unregisterAgent(client: RedisClient) { +export const unregisterAgentSchema = z.object({ + session_id: z + .string() + .optional() + .describe( + "Optional session_id returned by register_agent. Required when the transport does not preserve process-local registration state." + ), +}); + +export async function unregisterAgent( + client: RedisClient, + params: z.infer +) { + await ensureSessionBinding(client, params.session_id); + const name = client.requireRegistered(); await client.unregister(); return { content: [ { type: "text" as const, - text: `Agent "${client.agentName}" unregistered and cleaned up`, + text: `Agent "${name}" unregistered and cleaned up`, }, ], }; diff --git a/src/pty-wrapper/index.ts b/src/pty-wrapper/index.ts index 94e286f..5f8169e 100644 --- a/src/pty-wrapper/index.ts +++ b/src/pty-wrapper/index.ts @@ -9,9 +9,10 @@ function parseArgs(argv: string[]): { agent: string; cmd: string; args: string[] let cmdIndex = -1; for (let i = 2; i < argv.length; i++) { - if (argv[i] === "--agent" && i + 1 < argv.length) { - agent = argv[++i]; - } else if (argv[i] === "--cmd" && i + 1 < argv.length) { + const arg = argv[i]; + if (arg === "--agent" && i + 1 < argv.length) { + agent = argv[++i] ?? ""; + } else if (arg === "--cmd" && i + 1 < argv.length) { cmdIndex = i + 1; break; } @@ -22,7 +23,7 @@ function parseArgs(argv: string[]): { agent: string; cmd: string; args: string[] process.exit(1); } - const cmd = argv[cmdIndex]; + const cmd = argv[cmdIndex] ?? ""; const args = argv.slice(cmdIndex + 1); return { agent, cmd, args }; } diff --git a/src/pty-wrapper/redis-watcher.ts b/src/pty-wrapper/redis-watcher.ts index 4c0b71f..48b63cd 100644 --- a/src/pty-wrapper/redis-watcher.ts +++ b/src/pty-wrapper/redis-watcher.ts @@ -1,28 +1,86 @@ import { Redis } from "ioredis"; import { EventEmitter } from "events"; -import { REDIS_KEYS } from "../mcp-server/types.js"; +import { SESSION_KEYS } from "../core/keys.js"; +/** + * Watches for incoming messages in an agent's mailbox queue using + * Redis keyspace notifications instead of polling. + * + * Emits "message" with the queue depth whenever a new message arrives. + * + * Requires Redis to have keyspace notifications enabled for list events: + * CONFIG SET notify-keyspace-events Kl + * + * Falls back to LLEN polling if keyspace notifications are unavailable. + */ export class RedisWatcher extends EventEmitter { private redis: Redis; + private subscriber: Redis | null = null; private running = false; private readonly agentName: string; + private readonly redisUrl: string; constructor(agentName: string, redisUrl?: string) { super(); this.agentName = agentName; - const url = redisUrl || process.env.REDIS_URL || "redis://127.0.0.1:6379"; - this.redis = new Redis(url, { maxRetriesPerRequest: null }); + this.redisUrl = redisUrl || process.env.REDIS_URL || "redis://127.0.0.1:6379"; + this.redis = new Redis(this.redisUrl, { maxRetriesPerRequest: null }); } - /** Start the BLPOP watch loop. Emits "message" when a message arrives. */ async start(): Promise { this.running = true; - const queueKey = REDIS_KEYS.queue(this.agentName); + const queueKey = SESSION_KEYS.queue(this.agentName); + // Try to enable keyspace notifications and use pub/sub + try { + await this.redis.config("SET", "notify-keyspace-events", "Kl"); + await this.startKeyspaceWatch(queueKey); + } catch { + // Keyspace notifications unavailable (e.g., managed Redis), fall back to polling + await this.startPolling(queueKey); + } + } + + /** Watch via Redis keyspace notifications (SUBSCRIBE). */ + private async startKeyspaceWatch(queueKey: string): Promise { + this.subscriber = new Redis(this.redisUrl, { maxRetriesPerRequest: null }); + + // Subscribe to list events on the queue key + const db = 0; + const channel = `__keyspace@${db}__:${queueKey}`; + + this.subscriber.on("message", async (_ch: string, event: string) => { + if (!this.running) return; + // rpush/lpush indicate a new message was pushed + if (event === "rpush" || event === "lpush") { + try { + const len = await this.redis.llen(queueKey); + if (len > 0) { + this.emit("message", len); + } + } catch { + // Redis error during llen, ignore + } + } + }); + + await this.subscriber.subscribe(channel); + + // Also check for any messages already in the queue at startup + try { + const len = await this.redis.llen(queueKey); + if (len > 0) { + this.emit("message", len); + } + } catch { + // Ignore startup check errors + } + } + + /** Fallback: poll LLEN on the queue key. */ + private async startPolling(queueKey: string): Promise { while (this.running) { try { - // Peek (don't consume) -- we just want to know there's a message. - // The agent will consume it via receive_message MCP tool. const len = await this.redis.llen(queueKey); if (len > 0) { this.emit("message", len); @@ -34,7 +92,6 @@ export class RedisWatcher extends EventEmitter { } } catch { if (this.running) { - // Connection error, retry after delay await new Promise((r) => setTimeout(r, 2000)); } } @@ -43,6 +100,10 @@ export class RedisWatcher extends EventEmitter { async stop(): Promise { this.running = false; + if (this.subscriber) { + await this.subscriber.quit().catch(() => {}); + this.subscriber = null; + } await this.redis.quit(); } } diff --git a/src/transports/http.ts b/src/transports/http.ts new file mode 100644 index 0000000..eb76de5 --- /dev/null +++ b/src/transports/http.ts @@ -0,0 +1,132 @@ +#!/usr/bin/env node + +/** + * Streamable HTTP transport for gptqueue. + * + * Starts an Express server with the MCP Streamable HTTP transport. + * Each HTTP client gets its own MCP session and transport instance. + * This replaces the need for supergateway or other stdio bridges. + * + * Usage: + * node dist/transports/http.js [--port 3001] + */ + +import { randomUUID } from "node:crypto"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { RedisClient } from "../mcp-server/redis-client.js"; +import { registerTools } from "./setup-tools.js"; + +const DEFAULT_PORT = 3001; + +function parsePort(): number { + const idx = process.argv.indexOf("--port"); + const portArg = idx !== -1 ? process.argv[idx + 1] : undefined; + if (portArg) { + const p = parseInt(portArg, 10); + if (!isNaN(p)) return p; + } + return parseInt(process.env.GPTQ_HTTP_PORT || String(DEFAULT_PORT), 10); +} + +const port = parsePort(); +const app = createMcpExpressApp(); + +// Store transports by session ID +const transports: Record = {}; + +// Create a fresh MCP server + RedisClient per session +function createSessionServer(): { server: McpServer; redisClient: RedisClient } { + const redisClient = new RedisClient(null); + const server = new McpServer({ + name: "gptqueue", + version: "1.0.0", + }); + registerTools(server, redisClient); + return { server, redisClient }; +} + +// POST /mcp -- handle tool calls and initialization +app.post("/mcp", async (req, res) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + + if (sessionId && transports[sessionId]) { + // Existing session + await transports[sessionId].handleRequest(req, res, req.body); + return; + } + + if (!sessionId && isInitializeRequest(req.body)) { + // New session + const { server, redisClient } = createSessionServer(); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sid) => { + transports[sid] = transport; + }, + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + delete transports[sid]; + } + redisClient.shutdown().catch(() => {}); + }; + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } + + res.status(400).json({ error: "Bad request: missing session or not an init request" }); +}); + +// GET /mcp -- SSE stream for server-initiated messages +app.get("/mcp", async (req, res) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(404).json({ error: "Session not found" }); + return; + } + await transports[sessionId].handleRequest(req, res); +}); + +// DELETE /mcp -- close session +app.delete("/mcp", async (req, res) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(404).json({ error: "Session not found" }); + return; + } + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +// Health check +app.get("/health", (_req, res) => { + res.json({ + status: "ok", + sessions: Object.keys(transports).length, + }); +}); + +app.listen(port, () => { + console.log(`gptqueue HTTP server listening on port ${port}`); + console.log(`MCP endpoint: http://127.0.0.1:${port}/mcp`); + console.log(`Health check: http://127.0.0.1:${port}/health`); +}); + +// Graceful shutdown +async function shutdown() { + for (const [sid, transport] of Object.entries(transports)) { + await transport.close(); + delete transports[sid]; + } + process.exit(0); +} +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/src/transports/setup-tools.ts b/src/transports/setup-tools.ts new file mode 100644 index 0000000..5fdb842 --- /dev/null +++ b/src/transports/setup-tools.ts @@ -0,0 +1,74 @@ +/** + * Shared tool registration for all transports. + * + * Both stdio and HTTP entrypoints call this to register + * the same set of MCP tools on a server instance. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RedisClient } from "../mcp-server/redis-client.js"; +import { registerAgentSchema, registerAgent } from "../mcp-server/tools/register-agent.js"; +import { sendMessageSchema, sendMessage } from "../mcp-server/tools/send-message.js"; +import { receiveMessageSchema, receiveMessage } from "../mcp-server/tools/receive-message.js"; +import { listAgents } from "../mcp-server/tools/list-agents.js"; +import { queueStatusSchema, getQueueStatus } from "../mcp-server/tools/queue-status.js"; +import { + unregisterAgent, + unregisterAgentSchema, +} from "../mcp-server/tools/unregister-agent.js"; +import { + closeSession, + closeSessionSchema, +} from "../mcp-server/tools/close-session.js"; + +export function registerTools(server: McpServer, redisClient: RedisClient): void { + server.tool( + "register_agent", + "Register this agent with a name, role, and description. The agent MUST ask the user to choose a name if one was not provided via GPTQ_AGENT_NAME. Can also be called again to rename midway -- pending messages are migrated.", + registerAgentSchema.shape, + async (params) => registerAgent(redisClient, registerAgentSchema.parse(params)) + ); + + server.tool( + "send_message", + "Send a message to another agent's inbox queue. Retries with backoff if queue is full. Use session_id when the transport does not preserve process-local registration state.", + sendMessageSchema.shape, + async (params) => sendMessage(redisClient, sendMessageSchema.parse(params)) + ); + + server.tool( + "receive_message", + "Receive the next message from this agent's inbox (blocking). Returns the message or timeout. Use session_id when the transport does not preserve process-local registration state.", + receiveMessageSchema.shape, + async (params) => receiveMessage(redisClient, receiveMessageSchema.parse(params)) + ); + + server.tool( + "list_agents", + "Discover other agents. Returns each agent's name, description (what they do), role, and online/offline status. Use this to find the right agent to send_message to. Works before registration.", + {}, + async () => listAgents(redisClient) + ); + + server.tool( + "get_queue_status", + "Get queue depth and metadata for an agent (or all agents). Works before registration.", + queueStatusSchema.shape, + async (params) => getQueueStatus(redisClient, queueStatusSchema.parse(params)) + ); + + server.tool( + "close_session", + "Close the current session but preserve the mailbox. Messages remain queued and the agent can reconnect later. Use session_id when the transport does not preserve process-local registration state.", + closeSessionSchema.shape, + async (params) => closeSession(redisClient, closeSessionSchema.parse(params)) + ); + + server.tool( + "unregister_agent", + "Unregister this agent and clean up its queue data. Use session_id when the transport does not preserve process-local registration state.", + unregisterAgentSchema.shape, + async (params) => + unregisterAgent(redisClient, unregisterAgentSchema.parse(params)) + ); +} diff --git a/tests/cross-process-bug.test.ts b/tests/cross-process-bug.test.ts new file mode 100644 index 0000000..3a06d48 --- /dev/null +++ b/tests/cross-process-bug.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { RedisClient } from "../src/mcp-server/redis-client.js"; +import { Redis } from "ioredis"; + +const TEST_REDIS_URL = process.env.REDIS_URL || "redis://127.0.0.1:6379"; + +async function flushTestKeys(redis: Redis): Promise { + const keys = await redis.keys("gptq:*"); + if (keys.length > 0) await redis.del(...keys); +} + +describe("Cross-process registration bug (NXT-002)", () => { + let cleanup: Redis; + + beforeEach(async () => { + cleanup = new Redis(TEST_REDIS_URL, { maxRetriesPerRequest: 3 }); + await flushTestKeys(cleanup); + }); + + afterEach(async () => { + await flushTestKeys(cleanup); + await cleanup.quit(); + }); + + it("process B cannot operate on agent registered by process A", async () => { + // Process A registers the agent + const processA = new RedisClient(null, TEST_REDIS_URL); + await processA.register("both", "shared-agent", "registered by A"); + + // Verify registration is durable in Redis + const agents = await processA.listAgents(); + expect(agents.find((a) => a.name === "shared-agent")).toBeDefined(); + + // Process B is a fresh RedisClient (simulates a respawned worker) + const processB = new RedisClient(null, TEST_REDIS_URL); + + // Process B can see the agent in list_agents (Redis-backed) + const agentsFromB = await processB.listAgents(); + expect(agentsFromB.find((a) => a.name === "shared-agent")).toBeDefined(); + + // BUG: Process B cannot send or receive because _agentName is null + expect(processB.registered).toBe(false); + expect(() => processB.requireRegistered()).toThrow( + "Agent not registered" + ); + + // Process B cannot receive messages meant for shared-agent + // even though the agent is demonstrably registered in Redis + await processA.sendMessage({ + id: "cross-1", + from: "shared-agent", + to: "shared-agent", + timestamp: new Date().toISOString(), + type: "ping", + payload: { content: "self-ping" }, + }); + + // Process B should be able to receive this -- but it cannot + // because requireRegistered() checks in-memory _agentName, not Redis + expect(() => processB.requireRegistered()).toThrow(); + + await processA.shutdown(); + await processB.shutdown(); + }); + + it("re-registration in process B works but is a workaround, not a fix", async () => { + const processA = new RedisClient(null, TEST_REDIS_URL); + await processA.register("both", "workaround-agent", "registered by A"); + + // Process B must re-register to work -- this is the workaround + const processB = new RedisClient(null, TEST_REDIS_URL); + await processB.register("both", "workaround-agent", "re-registered by B"); + + // Send message after B is registered so BLPOP subscriber is ready + await processA.sendMessage({ + id: "wa-1", + from: "workaround-agent", + to: "workaround-agent", + timestamp: new Date().toISOString(), + type: "task", + payload: { content: "message from A" }, + }); + + // Verify queue has data + const depth = await processB.getQueueDepth(); + expect(depth).toBe(1); + + // Now B can receive -- but this required a full re-registration + // which overwrites metadata and restarts heartbeat + const msg = await processB.receiveMessage(2); + expect(msg).not.toBeNull(); + expect(msg!.payload.content).toBe("message from A"); + + await processA.shutdown(); + await processB.shutdown(); + }); + + it("reconnectSession lets process B resume a session without re-registering", async () => { + // Process A registers and gets a session_id + const processA = new RedisClient(null, TEST_REDIS_URL); + const result = await processA.register("both", "reconnect-agent", "will be reconnected"); + const sessionId = result.session_id; + + // Send a message to self + await processA.sendMessage({ + id: "rc-1", + from: "reconnect-agent", + to: "reconnect-agent", + timestamp: new Date().toISOString(), + type: "task", + payload: { content: "message before reconnect" }, + }); + + // Process B is a fresh instance -- simulates a new worker + const processB = new RedisClient(null, TEST_REDIS_URL); + expect(processB.registered).toBe(false); + + // Process B reconnects using the session_id from process A + const agentName = await processB.reconnectSession(sessionId); + expect(agentName).toBe("reconnect-agent"); + expect(processB.registered).toBe(true); + expect(processB.agentName).toBe("reconnect-agent"); + + // Process B can now receive the queued message + const msg = await processB.receiveMessage(2); + expect(msg).not.toBeNull(); + expect(msg!.payload.content).toBe("message before reconnect"); + + await processA.shutdown(); + await processB.shutdown(); + }); +}); diff --git a/tests/http-transport.test.ts b/tests/http-transport.test.ts new file mode 100644 index 0000000..bc34ae9 --- /dev/null +++ b/tests/http-transport.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { createServer, type Server } from "node:http"; +import { randomUUID } from "node:crypto"; +import { Redis } from "ioredis"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { RedisClient } from "../src/mcp-server/redis-client.js"; +import { registerTools } from "../src/transports/setup-tools.js"; + +const TEST_REDIS_URL = process.env.REDIS_URL || "redis://127.0.0.1:6379"; +const TEST_PORT = 3198; + +async function flushTestKeys(redis: Redis): Promise { + const keys = await redis.keys("gptq:*"); + if (keys.length > 0) await redis.del(...keys); +} + +describe("HTTP transport (NXT-018)", () => { + let httpServer: Server; + let cleanup: Redis; + const transports: Record = {}; + const redisClients: RedisClient[] = []; + + beforeAll(async () => { + // Start a minimal HTTP server with MCP Streamable HTTP transport + const { createMcpExpressApp } = await import( + "@modelcontextprotocol/sdk/server/express.js" + ); + const app = createMcpExpressApp(); + + app.post("/mcp", async (req: any, res: any) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + + if (sessionId && transports[sessionId]) { + await transports[sessionId].handleRequest(req, res, req.body); + return; + } + + if (!sessionId && isInitializeRequest(req.body)) { + const redisClient = new RedisClient(null, TEST_REDIS_URL); + redisClients.push(redisClient); + const server = new McpServer({ name: "gptqueue-test", version: "1.0.0" }); + registerTools(server, redisClient); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sid: string) => { + transports[sid] = transport; + }, + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid) delete transports[sid]; + redisClient.shutdown().catch(() => {}); + }; + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } + + res.status(400).json({ error: "Bad request" }); + }); + + app.get("/mcp", async (req: any, res: any) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(404).json({ error: "Session not found" }); + return; + } + await transports[sessionId].handleRequest(req, res); + }); + + app.delete("/mcp", async (req: any, res: any) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(404).json({ error: "Session not found" }); + return; + } + await transports[sessionId].handleRequest(req, res); + }); + + httpServer = createServer(app); + await new Promise((resolve) => httpServer.listen(TEST_PORT, resolve)); + }); + + afterAll(async () => { + for (const transport of Object.values(transports)) { + await transport.close(); + } + for (const rc of redisClients) { + await rc.shutdown().catch(() => {}); + } + await new Promise((resolve) => httpServer.close(() => resolve())); + }); + + beforeEach(async () => { + cleanup = new Redis(TEST_REDIS_URL, { maxRetriesPerRequest: 3 }); + await flushTestKeys(cleanup); + await cleanup.quit(); + }); + + it("connects via HTTP, registers, lists agents", async () => { + const clientTransport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${TEST_PORT}/mcp`) + ); + const client = new Client({ name: "test-client", version: "1.0.0" }); + await client.connect(clientTransport); + + // Register + const regResult = await client.callTool({ + name: "register_agent", + arguments: { + name: "http-agent-1", + role: "both", + description: "HTTP test agent", + }, + }); + const regParsed = JSON.parse((regResult.content as any)[0].text); + expect(regParsed.status).toBe("registered"); + expect(regParsed.name).toBe("http-agent-1"); + expect(regParsed.session_id).toBeTruthy(); + + // List agents + const listResult = await client.callTool({ + name: "list_agents", + arguments: {}, + }); + const agents = JSON.parse((listResult.content as any)[0].text); + expect(agents.find((a: any) => a.name === "http-agent-1")).toBeDefined(); + + await client.close(); + }); + + it("sends and receives messages over HTTP between two clients", async () => { + // Client A + const transportA = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${TEST_PORT}/mcp`) + ); + const clientA = new Client({ name: "client-a", version: "1.0.0" }); + await clientA.connect(transportA); + + await clientA.callTool({ + name: "register_agent", + arguments: { name: "http-sender", role: "publisher", description: "sends" }, + }); + + // Client B + const transportB = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${TEST_PORT}/mcp`) + ); + const clientB = new Client({ name: "client-b", version: "1.0.0" }); + await clientB.connect(transportB); + + await clientB.callTool({ + name: "register_agent", + arguments: { name: "http-receiver", role: "consumer", description: "receives" }, + }); + + // Send from A to B + const sendResult = await clientA.callTool({ + name: "send_message", + arguments: { + to: "http-receiver", + content: "hello over HTTP", + type: "task", + }, + }); + const sendParsed = JSON.parse((sendResult.content as any)[0].text); + expect(sendParsed.status).toBe("sent"); + + // Receive on B + const recvResult = await clientB.callTool({ + name: "receive_message", + arguments: { timeout: 3 }, + }); + const msg = JSON.parse((recvResult.content as any)[0].text); + expect(msg.payload.content).toBe("hello over HTTP"); + expect(msg.from).toBe("http-sender"); + + await clientA.close(); + await clientB.close(); + }); +}); diff --git a/tests/multi-session-policy.test.ts b/tests/multi-session-policy.test.ts new file mode 100644 index 0000000..2df7448 --- /dev/null +++ b/tests/multi-session-policy.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { RedisClient } from "../src/mcp-server/redis-client.js"; +import { Redis } from "ioredis"; + +const TEST_REDIS_URL = process.env.REDIS_URL || "redis://127.0.0.1:6379"; + +async function flushTestKeys(redis: Redis): Promise { + const keys = await redis.keys("gptq:*"); + if (keys.length > 0) await redis.del(...keys); +} + +/** + * Multi-session policy (NXT-020): + * - Any session for an agent can send messages on that agent's behalf + * - All sessions share a single mailbox queue (last-writer-wins for send) + * - Any session can consume from the shared mailbox + * - Agent is online if ANY session has a live lease + */ +describe("Multi-session policy (NXT-020)", () => { + let cleanup: Redis; + + beforeEach(async () => { + cleanup = new Redis(TEST_REDIS_URL, { maxRetriesPerRequest: 3 }); + await flushTestKeys(cleanup); + }); + + afterEach(async () => { + await flushTestKeys(cleanup); + await cleanup.quit(); + }); + + it("two sessions for the same agent can both send messages", async () => { + const sessionA = new RedisClient(null, TEST_REDIS_URL); + const sessionB = new RedisClient(null, TEST_REDIS_URL); + const receiver = new RedisClient(null, TEST_REDIS_URL); + + await sessionA.register("both", "multi-sender", "session A"); + await sessionB.register("both", "multi-sender", "session B"); + await receiver.register("consumer", "multi-receiver", "receives"); + + // Both sessions can send on behalf of multi-sender + await sessionA.sendMessage({ + id: "ms-1", + from: "multi-sender", + to: "multi-receiver", + timestamp: new Date().toISOString(), + type: "task", + payload: { content: "from session A" }, + }); + + await sessionB.sendMessage({ + id: "ms-2", + from: "multi-sender", + to: "multi-receiver", + timestamp: new Date().toISOString(), + type: "task", + payload: { content: "from session B" }, + }); + + const depth = await receiver.getQueueDepth(); + expect(depth).toBe(2); + + // Receiver gets both messages + const msg1 = await receiver.receiveMessage(2); + const msg2 = await receiver.receiveMessage(2); + expect(msg1).not.toBeNull(); + expect(msg2).not.toBeNull(); + + const contents = [msg1!.payload.content, msg2!.payload.content].sort(); + expect(contents).toEqual(["from session A", "from session B"]); + + await sessionA.shutdown(); + await sessionB.shutdown(); + await receiver.shutdown(); + }); + + it("closing one session keeps agent online via the other session", async () => { + const sessionA = new RedisClient(null, TEST_REDIS_URL); + const sessionB = new RedisClient(null, TEST_REDIS_URL); + + await sessionA.register("both", "dual-session", "session A"); + await sessionB.register("both", "dual-session", "session B"); + + // Both sessions active -- agent is online + let agents = await sessionA.listAgents(); + let found = agents.find((a) => a.name === "dual-session"); + expect(found?.online).toBe(true); + + // Close session A + await sessionA.closeCurrentSession(); + + // Agent should still be online via session B + agents = await sessionB.listAgents(); + found = agents.find((a) => a.name === "dual-session"); + expect(found?.online).toBe(true); + + await sessionA.shutdown(); + await sessionB.shutdown(); + }); +}); diff --git a/tests/redis-client.test.ts b/tests/redis-client.test.ts new file mode 100644 index 0000000..1c04b11 --- /dev/null +++ b/tests/redis-client.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { RedisClient } from "../src/mcp-server/redis-client.js"; +import { Redis } from "ioredis"; + +const TEST_REDIS_URL = process.env.REDIS_URL || "redis://127.0.0.1:6379"; + +/** Flush all gptq:* keys used by tests. */ +async function flushTestKeys(redis: Redis): Promise { + const keys = await redis.keys("gptq:*"); + if (keys.length > 0) await redis.del(...keys); +} + +describe("RedisClient", () => { + let cleanup: Redis; + + beforeEach(async () => { + cleanup = new Redis(TEST_REDIS_URL, { maxRetriesPerRequest: 3 }); + await flushTestKeys(cleanup); + }); + + afterEach(async () => { + await flushTestKeys(cleanup); + await cleanup.quit(); + }); + + it("registers an agent and verifies registered state", async () => { + const client = new RedisClient(null, TEST_REDIS_URL); + expect(client.registered).toBe(false); + + const result = await client.register("both", "test-agent-1", "a test agent"); + expect(result.name).toBe("test-agent-1"); + expect(result.session_id).toBeTruthy(); + expect(client.registered).toBe(true); + expect(client.agentName).toBe("test-agent-1"); + + await client.shutdown(); + }); + + it("sends and receives a message round-trip", async () => { + const sender = new RedisClient(null, TEST_REDIS_URL); + const receiver = new RedisClient(null, TEST_REDIS_URL); + + await sender.register("publisher", "sender-1", "sends messages"); + await receiver.register("consumer", "receiver-1", "receives messages"); + + const sent = await sender.sendMessage({ + id: "msg-001", + from: "sender-1", + to: "receiver-1", + timestamp: new Date().toISOString(), + type: "task", + payload: { content: "hello from sender" }, + }); + expect(sent).toBe(true); + + const received = await receiver.receiveMessage(2); + expect(received).not.toBeNull(); + expect(received!.id).toBe("msg-001"); + expect(received!.payload.content).toBe("hello from sender"); + + await sender.shutdown(); + await receiver.shutdown(); + }); + + it("lists registered agents with online status", async () => { + const client = new RedisClient(null, TEST_REDIS_URL); + await client.register("both", "list-test-agent", "for listing"); + + const agents = await client.listAgents(); + const found = agents.find((a) => a.name === "list-test-agent"); + expect(found).toBeDefined(); + expect(found!.online).toBe(true); + expect(found!.role).toBe("both"); + + await client.shutdown(); + }); + + it("unregisters and cleans up all keys", async () => { + const client = new RedisClient(null, TEST_REDIS_URL); + await client.register("both", "unreg-agent", "will be removed"); + + await client.unregister(); + expect(client.registered).toBe(false); + + const agents = await client.listAgents(); + const found = agents.find((a) => a.name === "unreg-agent"); + expect(found).toBeUndefined(); + + await client.shutdown(); + }); + + it("reports queue depth correctly", async () => { + const sender = new RedisClient(null, TEST_REDIS_URL); + const receiver = new RedisClient(null, TEST_REDIS_URL); + + await sender.register("publisher", "depth-sender", "sends"); + await receiver.register("consumer", "depth-receiver", "receives"); + + await sender.sendMessage({ + id: "d-1", + from: "depth-sender", + to: "depth-receiver", + timestamp: new Date().toISOString(), + type: "ping", + payload: { content: "ping" }, + }); + await sender.sendMessage({ + id: "d-2", + from: "depth-sender", + to: "depth-receiver", + timestamp: new Date().toISOString(), + type: "ping", + payload: { content: "ping2" }, + }); + + const depth = await receiver.getQueueDepth(); + expect(depth).toBe(2); + + await sender.shutdown(); + await receiver.shutdown(); + }); +}); diff --git a/tests/session-scoped-tools.test.ts b/tests/session-scoped-tools.test.ts new file mode 100644 index 0000000..dae3fc6 --- /dev/null +++ b/tests/session-scoped-tools.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { Redis } from "ioredis"; +import { RedisClient } from "../src/mcp-server/redis-client.js"; +import { + registerAgent, + registerAgentSchema, +} from "../src/mcp-server/tools/register-agent.js"; +import { + sendMessage, + sendMessageSchema, +} from "../src/mcp-server/tools/send-message.js"; +import { + receiveMessage, + receiveMessageSchema, +} from "../src/mcp-server/tools/receive-message.js"; + +const TEST_REDIS_URL = process.env.REDIS_URL || "redis://127.0.0.1:6379"; + +async function flushTestKeys(redis: Redis): Promise { + const keys = await redis.keys("gptq:*"); + if (keys.length > 0) await redis.del(...keys); +} + +function parseTextPayload(result: { + content: readonly { type: "text"; text: string }[]; +}): any { + return JSON.parse(result.content[0]!.text); +} + +describe("Session-scoped tools across fresh clients", () => { + let cleanup: Redis; + + beforeEach(async () => { + cleanup = new Redis(TEST_REDIS_URL, { maxRetriesPerRequest: 3 }); + await flushTestKeys(cleanup); + }); + + afterEach(async () => { + await flushTestKeys(cleanup); + await cleanup.quit(); + }); + + it("sends and receives with session_id after transport-local state is lost", async () => { + const registerSenderClient = new RedisClient(null, TEST_REDIS_URL); + const registerReceiverClient = new RedisClient(null, TEST_REDIS_URL); + + const senderRegistration = parseTextPayload( + await registerAgent( + registerSenderClient, + registerAgentSchema.parse({ + name: "stateless-sender", + role: "publisher", + description: "sender registered in one process", + }) + ) + ); + const receiverRegistration = parseTextPayload( + await registerAgent( + registerReceiverClient, + registerAgentSchema.parse({ + name: "stateless-receiver", + role: "consumer", + description: "receiver registered in one process", + }) + ) + ); + + await registerSenderClient.shutdown(); + await registerReceiverClient.shutdown(); + + const sendClient = new RedisClient(null, TEST_REDIS_URL); + const sendResult = parseTextPayload( + await sendMessage( + sendClient, + sendMessageSchema.parse({ + session_id: senderRegistration.session_id, + to: "stateless-receiver", + content: "hello from a fresh client", + type: "task", + }) + ) + ); + expect(sendResult.status).toBe("sent"); + + const receiveClient = new RedisClient(null, TEST_REDIS_URL); + const received = parseTextPayload( + await receiveMessage( + receiveClient, + receiveMessageSchema.parse({ + session_id: receiverRegistration.session_id, + timeout: 2, + }) + ) + ); + + expect(received.payload.content).toBe("hello from a fresh client"); + expect(received.from).toBe("stateless-sender"); + expect(received.to).toBe("stateless-receiver"); + + await sendClient.shutdown(); + await receiveClient.shutdown(); + }); +}); diff --git a/tests/session-store.test.ts b/tests/session-store.test.ts new file mode 100644 index 0000000..05907ee --- /dev/null +++ b/tests/session-store.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { Redis } from "ioredis"; +import { SessionStore } from "../src/core/session-store.js"; +import { SESSION_KEYS } from "../src/core/keys.js"; + +const TEST_REDIS_URL = process.env.REDIS_URL || "redis://127.0.0.1:6379"; + +async function flushTestKeys(redis: Redis): Promise { + const keys = await redis.keys("gptq:*"); + if (keys.length > 0) await redis.del(...keys); +} + +describe("SessionStore", () => { + let redis: Redis; + let store: SessionStore; + + beforeEach(async () => { + redis = new Redis(TEST_REDIS_URL, { maxRetriesPerRequest: 3 }); + await flushTestKeys(redis); + store = new SessionStore(redis); + }); + + afterEach(async () => { + store.stopLeaseRefresh(); + await flushTestKeys(redis); + await redis.quit(); + }); + + it("creates a session with a unique ID and stores it in Redis", async () => { + const session = await store.createSession("agent-a", "both", "test agent"); + + expect(session.session_id).toBeTruthy(); + expect(session.agent_name).toBe("agent-a"); + expect(session.role).toBe("both"); + + // Verify Redis state + const stored = await store.getSession(session.session_id); + expect(stored).not.toBeNull(); + expect(stored!.agent_name).toBe("agent-a"); + + // Verify agent-sessions set + const sessions = await redis.smembers( + SESSION_KEYS.agentSessions("agent-a") + ); + expect(sessions).toContain(session.session_id); + }); + + it("refreshes lease and extends TTL", async () => { + const session = await store.createSession("agent-b", "consumer"); + + const lease1 = await store.getLeaseState(session.session_id); + expect(lease1.alive).toBe(true); + expect(lease1.ttl_seconds).toBeGreaterThan(0); + + await store.refreshLease(session.session_id); + + const lease2 = await store.getLeaseState(session.session_id); + expect(lease2.alive).toBe(true); + expect(lease2.ttl_seconds).toBeGreaterThan(0); + }); + + it("closes a session without deleting the mailbox", async () => { + const session = await store.createSession("agent-c", "both"); + + // Simulate a mailbox with data + await redis.rpush(SESSION_KEYS.queue("agent-c"), "test-message"); + + const agentName = await store.closeSession(session.session_id); + expect(agentName).toBe("agent-c"); + + // Session is gone + const closed = await store.getSession(session.session_id); + expect(closed).toBeNull(); + + // Mailbox data is preserved + const queueLen = await redis.llen(SESSION_KEYS.queue("agent-c")); + expect(queueLen).toBe(1); + }); + + it("reports agent as offline when all sessions are closed", async () => { + const session = await store.createSession("agent-d", "both"); + + const before = await store.getPresence("agent-d"); + expect(before.online).toBe(true); + expect(before.active_sessions).toHaveLength(1); + + await store.closeSession(session.session_id); + + const after = await store.getPresence("agent-d"); + expect(after.online).toBe(false); + expect(after.active_sessions).toHaveLength(0); + }); + + it("supports multiple sessions for one agent", async () => { + const s1 = await store.createSession("multi-agent", "both", "session 1"); + const s2 = await store.createSession("multi-agent", "both", "session 2"); + + const presence = await store.getPresence("multi-agent"); + expect(presence.online).toBe(true); + expect(presence.active_sessions).toHaveLength(2); + + // Close one session -- agent remains online + await store.closeSession(s1.session_id); + + const afterOne = await store.getPresence("multi-agent"); + expect(afterOne.online).toBe(true); + expect(afterOne.active_sessions).toHaveLength(1); + + // Close second -- agent goes offline + await store.closeSession(s2.session_id); + + const afterBoth = await store.getPresence("multi-agent"); + expect(afterBoth.online).toBe(false); + }); + + it("resolves agent name from session ID", async () => { + const session = await store.createSession("lookup-agent", "publisher"); + + const resolved = await store.resolveAgent(session.session_id); + expect(resolved).toBe("lookup-agent"); + + // Non-existent session returns null + const missing = await store.resolveAgent("no-such-session"); + expect(missing).toBeNull(); + }); + + it("lists all sessions for an agent", async () => { + await store.createSession("list-agent", "both", "first"); + await store.createSession("list-agent", "both", "second"); + + const sessions = await store.listSessions("list-agent"); + expect(sessions).toHaveLength(2); + }); +}); diff --git a/tests/tool-contract.test.ts b/tests/tool-contract.test.ts new file mode 100644 index 0000000..1a844b8 --- /dev/null +++ b/tests/tool-contract.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { RedisClient } from "../src/mcp-server/redis-client.js"; +import { Redis } from "ioredis"; + +const TEST_REDIS_URL = process.env.REDIS_URL || "redis://127.0.0.1:6379"; + +async function flushTestKeys(redis: Redis): Promise { + const keys = await redis.keys("gptq:*"); + if (keys.length > 0) await redis.del(...keys); +} + +describe("Tool contract (NXT-015)", () => { + let cleanup: Redis; + + beforeEach(async () => { + cleanup = new Redis(TEST_REDIS_URL, { maxRetriesPerRequest: 3 }); + await flushTestKeys(cleanup); + }); + + afterEach(async () => { + await flushTestKeys(cleanup); + await cleanup.quit(); + }); + + it("register -> send -> crash -> reconnect -> receive (session survives crash)", async () => { + // Agent A sends a message to agent B + const clientA = new RedisClient(null, TEST_REDIS_URL); + const clientB1 = new RedisClient(null, TEST_REDIS_URL); + + await clientA.register("publisher", "tool-sender", "sends stuff"); + const regB = await clientB1.register("consumer", "tool-receiver", "receives stuff"); + + await clientA.sendMessage({ + id: "tc-1", + from: "tool-sender", + to: "tool-receiver", + timestamp: new Date().toISOString(), + type: "task", + payload: { content: "important work" }, + }); + + // B "crashes" -- shutdown without close_session (session remains in Redis) + await clientB1.shutdown(); + + // New process for B reconnects with original session_id + const clientB2 = new RedisClient(null, TEST_REDIS_URL); + const resumed = await clientB2.reconnectSession(regB.session_id); + expect(resumed).toBe("tool-receiver"); + + // B2 can receive the queued message + const msg = await clientB2.receiveMessage(2); + expect(msg).not.toBeNull(); + expect(msg!.payload.content).toBe("important work"); + + await clientA.shutdown(); + await clientB2.shutdown(); + }); + + it("close_session preserves mailbox for later re-registration", async () => { + const client = new RedisClient(null, TEST_REDIS_URL); + await client.register("both", "close-test", "will close session"); + + // Send a message to self + await client.sendMessage({ + id: "cs-1", + from: "close-test", + to: "close-test", + timestamp: new Date().toISOString(), + type: "task", + payload: { content: "preserved message" }, + }); + + // Close session -- mailbox preserved + const closedName = await client.closeCurrentSession(); + expect(closedName).toBe("close-test"); + expect(client.registered).toBe(false); + + // Verify mailbox still has the message + const depth = await cleanup.llen("gptq:q:close-test"); + expect(depth).toBe(1); + + // Re-register as the same agent (new session) and receive the message + const client2 = new RedisClient(null, TEST_REDIS_URL); + await client2.register("both", "close-test", "re-registered"); + + const msg = await client2.receiveMessage(2); + expect(msg).not.toBeNull(); + expect(msg!.payload.content).toBe("preserved message"); + + await client.shutdown(); + await client2.shutdown(); + }); + + it("unregister deletes mailbox while close_session preserves it", async () => { + const client = new RedisClient(null, TEST_REDIS_URL); + await client.register("both", "delete-test", "will be deleted"); + + // Send a message to self + await client.sendMessage({ + id: "dt-1", + from: "delete-test", + to: "delete-test", + timestamp: new Date().toISOString(), + type: "ping", + payload: { content: "keep me?" }, + }); + + // Unregister (destructive) -- mailbox should be deleted + await client.unregister(); + + const depth = await cleanup.llen("gptq:q:delete-test"); + expect(depth).toBe(0); + + // Agent should not appear in list + const client2 = new RedisClient(null, TEST_REDIS_URL); + const agents = await client2.listAgents(); + expect(agents.find((a) => a.name === "delete-test")).toBeUndefined(); + + await client.shutdown(); + await client2.shutdown(); + }); + + it("register returns session_id that can be used to reconnect", async () => { + const client = new RedisClient(null, TEST_REDIS_URL); + const result = await client.register("both", "session-test", "test"); + + expect(result.session_id).toBeTruthy(); + expect(result.name).toBe("session-test"); + expect(client.sessionId).toBe(result.session_id); + + await client.shutdown(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 6e3ad2c..e5c7e6d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "outDir": "dist", "rootDir": "src", "strict": true, + "noUncheckedIndexedAccess": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9b6e455 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + testTimeout: 15_000, + hookTimeout: 10_000, + fileParallelism: false, + }, +});