llm-cli-gateway 1.15.3 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +41 -5
- package/dist/config.d.ts +2 -17
- package/dist/config.js +6 -24
- package/dist/db.d.ts +3 -13
- package/dist/db.js +6 -78
- package/dist/health.d.ts +1 -8
- package/dist/health.js +2 -18
- package/dist/index.js +2 -2
- package/dist/migrate-sessions.js +3 -5
- package/dist/session-manager-pg.d.ts +13 -42
- package/dist/session-manager-pg.js +25 -277
- package/dist/session-manager.js +3 -3
- package/package.json +1 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,41 @@ All notable changes to the llm-cli-gateway project.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## [1.16.0] - 2026-05-29 — remove Redis session dependency
|
|
8
|
+
|
|
9
|
+
Feature release that removes the optional Redis/ioredis layer from the
|
|
10
|
+
PostgreSQL-backed session manager and tightens the public README around the
|
|
11
|
+
project's current demand and quality signals.
|
|
12
|
+
|
|
13
|
+
### Removed
|
|
14
|
+
|
|
15
|
+
- Removed the optional `ioredis` peer/dev dependency and its transitive
|
|
16
|
+
packages from the install graph.
|
|
17
|
+
- Removed `REDIS_URL` as a requirement for PostgreSQL-backed sessions.
|
|
18
|
+
- Removed Redis from the PostgreSQL test Docker Compose stack and PG test
|
|
19
|
+
harness.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- PostgreSQL-backed sessions now require only `DATABASE_URL` plus the optional
|
|
24
|
+
`pg` peer dependency. PostgreSQL remains the source of truth for session
|
|
25
|
+
records and active-session state.
|
|
26
|
+
- Simplified database health reporting to PostgreSQL connectivity only.
|
|
27
|
+
- Simplified the PG session manager by removing Redis cache-aside reads/writes
|
|
28
|
+
and Redis lock handling.
|
|
29
|
+
- Updated migration and testing docs to describe the Postgres-only backend.
|
|
30
|
+
- Updated release-readiness and Socket-alert documentation now that the Redis
|
|
31
|
+
client dependency is no longer present.
|
|
32
|
+
- Refocused the README first screen around the strongest current trust and
|
|
33
|
+
demand signals: npm monthly downloads, passing CI/security workflows,
|
|
34
|
+
OpenSSF status, Sigstore-signed releases, and MIT licensing.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- Added `docs/plans/provider-workflow-assets.dag.toml`, a machine-readable
|
|
39
|
+
implementation plan for provider-specific skill and DAG-TOML pairs for
|
|
40
|
+
Claude, Codex, Gemini, Grok, and Mistral Vibe.
|
|
41
|
+
|
|
7
42
|
## [1.15.3] - 2026-05-29 — remove retired PyPI plugin
|
|
8
43
|
|
|
9
44
|
Patch release removing the retired Python `llm` plugin integration so the
|
package/README.md
CHANGED
|
@@ -3,19 +3,38 @@
|
|
|
3
3
|
[](https://github.com/verivus-oss/llm-cli-gateway/actions/workflows/ci.yml)
|
|
4
4
|
[](https://github.com/verivus-oss/llm-cli-gateway/actions/workflows/security.yml)
|
|
5
5
|
[](https://scorecard.dev/viewer/?uri=github.com/verivus-oss/llm-cli-gateway)
|
|
6
|
-
[](https://www.bestpractices.dev/projects/13025)
|
|
7
6
|
[](https://www.npmjs.com/package/llm-cli-gateway)
|
|
8
|
-
[](https://www.npmjs.com/package/llm-cli-gateway)
|
|
9
7
|
[](https://www.npmjs.com/package/llm-cli-gateway)
|
|
10
|
-
[](https://github.com/verivus-oss/llm-cli-gateway/releases)
|
|
11
8
|
[](LICENSE)
|
|
12
|
-
[](SECURITY.md#release-signing)
|
|
13
9
|
|
|
14
10
|
> _"Without consultation, plans are frustrated, but with many counselors they succeed."_
|
|
15
11
|
> — Proverbs 15:22 (LSB)
|
|
16
12
|
|
|
17
13
|
A Model Context Protocol (MCP) gateway for running Claude Code, Codex, Gemini, Grok, and Mistral (Vibe) CLIs from one MCP endpoint, with durable async jobs, session continuity, cache-aware prompting, observability, and personal-appliance setup tooling.
|
|
18
14
|
|
|
15
|
+
**Why developers try it:** one local MCP endpoint for cross-LLM validation, multi-agent coding workflows, and repeatable assistant-led setup across five provider CLIs.
|
|
16
|
+
|
|
17
|
+
**Current signals:** crossed 5k monthly npm downloads in May 2026; live npm downloads are shown above. CI and security workflows pass on `main`, OpenSSF Scorecard is published, OpenSSF Best Practices is passing, releases use Sigstore signing, and the package is MIT licensed.
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g llm-cli-gateway
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or use directly with `npx` from an MCP client:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"llm-gateway": {
|
|
31
|
+
"command": "npx",
|
|
32
|
+
"args": ["-y", "llm-cli-gateway"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
19
38
|
## What It Provides Today
|
|
20
39
|
|
|
21
40
|
`llm-cli-gateway` is a single-user MCP gateway for cross-LLM validation and multi-agent coding workflows. It is more than a thin CLI wrapper:
|
|
@@ -27,6 +46,24 @@ A Model Context Protocol (MCP) gateway for running Claude Code, Codex, Gemini, G
|
|
|
27
46
|
- Can run requests inside gateway-managed git worktrees for isolated multi-agent review and implementation loops.
|
|
28
47
|
- Ships personal-appliance setup surfaces: HTTP transport with bearer-token auth, `doctor --json`, setup UI artifacts, provider setup snippets, Docker fallback, and checked release bundles.
|
|
29
48
|
|
|
49
|
+
## Workflow Assets
|
|
50
|
+
|
|
51
|
+
The repo ships agent-ready workflow skills under [`.agents/skills`](.agents/skills) for async orchestration, session continuity, multi-LLM review, implement-review-fix loops, and secure approval-gated dispatch. Machine-readable DAG-TOML plans live under [`docs/plans`](docs/plans) and [`setup/install-plan.dag.toml`](setup/install-plan.dag.toml) for workflows that need deterministic sequencing and verification gates.
|
|
52
|
+
|
|
53
|
+
The next documentation focus is provider-specific skill and DAG-TOML pairs for each outbound CLI: Claude, Codex, Gemini, Grok, and Mistral Vibe. The implementation plan is tracked in [`docs/plans/provider-workflow-assets.dag.toml`](docs/plans/provider-workflow-assets.dag.toml), with each provider asset expected to cover install/login checks, session behavior, approval modes, cache/telemetry surfaces, failure modes, and a smoke-test gate.
|
|
54
|
+
|
|
55
|
+
## Trust & Supply Chain
|
|
56
|
+
|
|
57
|
+
[](https://www.bestpractices.dev/projects/13025)
|
|
58
|
+
[](https://www.npmjs.com/package/llm-cli-gateway)
|
|
59
|
+
[](https://github.com/verivus-oss/llm-cli-gateway/releases)
|
|
60
|
+
[](SECURITY.md#release-signing)
|
|
61
|
+
|
|
62
|
+
- CI runs build, lint, format, tests, package checks, and npm audit.
|
|
63
|
+
- Security CI runs actionlint, zizmor, shellcheck, typos, osv-scanner, gitleaks, and lychee.
|
|
64
|
+
- GitHub release installer artifacts are checksummed and signed with Sigstore keyless signing.
|
|
65
|
+
- npm releases use provenance through OIDC trusted publishing.
|
|
66
|
+
|
|
30
67
|
## Personal MCP Appliance
|
|
31
68
|
|
|
32
69
|
The personal-appliance contract keeps that surface intentionally narrow: one trusted user runs the gateway on a machine or volume they own, connects one MCP endpoint, and asks any connected client for cross-LLM validation.
|
|
@@ -1143,7 +1180,6 @@ If you're vetting `llm-cli-gateway` through [Socket](https://socket.dev/npm/pack
|
|
|
1143
1180
|
| **Shell access** | `src/executor.ts` uses `child_process.spawn(cmd, args, …)` to invoke the underlying LLM CLIs. | `spawn` is called with an argument array and **never** `shell: true`, so there is no shell interpolation path for caller input. The command name is restricted to an allow-list of known CLI binaries (`claude`, `codex`, `gemini`, `grok`, `vibe`). |
|
|
1144
1181
|
| **Uses eval** | None in our source. Transitive: `@modelcontextprotocol/sdk` → `ajv@8` uses `new Function(...)` in `ajv/dist/compile/index.js` to compile JSON Schema validators. | This is ajv's standard codegen path. Only known schemas (defined in our source and the MCP SDK) flow into it; no caller-supplied data ever reaches the compiled function body. |
|
|
1145
1182
|
| **better-sqlite3 PRAGMA helper** | Transitive: `better-sqlite3/lib/methods/pragma.js` interpolates its caller-provided `source` into a `PRAGMA ${source}` statement. | We do not call `db.pragma()` from production source. Internal SQLite setup uses fixed literal `db.exec("PRAGMA ...")` statements, and `npm run security:audit` fails the release if production code reintroduces `.pragma()` calls. |
|
|
1146
|
-
| **ioredis obfuscated code** | Optional peer/dev dependency: `ioredis@5.10.1` may be flagged at `built/constants/TLSProfiles.js` for base64-looking strings. | Reviewed as a false positive. The file is a Redis Cloud TLS CA certificate bundle in PEM format, which is base64 by design. It contains no decoder loop, dynamic evaluation, network call, or hidden execution path. The same file is byte-for-byte identical in `ioredis@5.9.2`; our default production install does not install `ioredis`, and our code does not pass ioredis TLS profile options. |
|
|
1147
1183
|
| **Dependency ownership** | A handful of small transitive packages (e.g. `bindings` via `better-sqlite3`, `media-typer` via `@modelcontextprotocol/sdk`) trip Socket's "unstable ownership" or "obfuscated code" heuristics. | These are pinned, well-known micro-deps in the Node ecosystem with no known issues. We pin direct override versions of `content-type` and `type-is` in `package.json#overrides`. Our previous direct dependency on `toml@3.0.0` (also single-maintainer, last released 2020) was replaced with the actively-maintained `smol-toml` to reduce inherited risk. |
|
|
1148
1184
|
|
|
1149
1185
|
See [`socket.yml`](./socket.yml) for the same context in machine-readable form.
|
package/dist/config.d.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
1
|
import type { Logger } from "./logger.js";
|
|
2
|
-
export interface CacheTtl {
|
|
3
|
-
session: number;
|
|
4
|
-
activeSession: number;
|
|
5
|
-
sessionList: number;
|
|
6
|
-
}
|
|
7
2
|
export interface DatabaseConfig {
|
|
8
3
|
connectionString: string;
|
|
9
4
|
pool: {
|
|
@@ -13,25 +8,15 @@ export interface DatabaseConfig {
|
|
|
13
8
|
statementTimeout: number;
|
|
14
9
|
};
|
|
15
10
|
}
|
|
16
|
-
export interface RedisConfig {
|
|
17
|
-
url: string;
|
|
18
|
-
retryStrategy: {
|
|
19
|
-
maxRetries: number;
|
|
20
|
-
initialDelay: number;
|
|
21
|
-
maxDelay: number;
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
11
|
export declare const DEFAULT_SESSION_TTL_SECONDS = 2592000;
|
|
25
12
|
export interface Config {
|
|
26
13
|
database?: DatabaseConfig;
|
|
27
|
-
redis?: RedisConfig;
|
|
28
|
-
cacheTtl: CacheTtl;
|
|
29
14
|
sessionTtl: number;
|
|
30
15
|
}
|
|
31
16
|
/**
|
|
32
17
|
* Load configuration from environment variables.
|
|
33
|
-
* Always returns a Config object with base fields
|
|
34
|
-
* Database
|
|
18
|
+
* Always returns a Config object with base fields.
|
|
19
|
+
* Database fields are populated when DATABASE_URL is set.
|
|
35
20
|
*/
|
|
36
21
|
export declare function loadConfig(): Config;
|
|
37
22
|
export declare const PERSISTENCE_BACKENDS: readonly ["sqlite", "postgres", "memory", "none"];
|
package/dist/config.js
CHANGED
|
@@ -11,37 +11,28 @@ const DatabaseUrlSchema = z
|
|
|
11
11
|
.refine(url => url.startsWith("postgresql://") || url.startsWith("postgres://"), {
|
|
12
12
|
message: "Database URL must start with postgresql:// or postgres://",
|
|
13
13
|
});
|
|
14
|
-
const RedisUrlSchema = z.string().url().startsWith("redis://");
|
|
15
14
|
export const DEFAULT_SESSION_TTL_SECONDS = 2592000; // 30 days
|
|
16
15
|
/**
|
|
17
16
|
* Load configuration from environment variables.
|
|
18
|
-
* Always returns a Config object with base fields
|
|
19
|
-
* Database
|
|
17
|
+
* Always returns a Config object with base fields.
|
|
18
|
+
* Database fields are populated when DATABASE_URL is set.
|
|
20
19
|
*/
|
|
21
20
|
export function loadConfig() {
|
|
22
21
|
const databaseUrl = process.env.DATABASE_URL;
|
|
23
|
-
const redisUrl = process.env.REDIS_URL;
|
|
24
|
-
// Default cache TTLs
|
|
25
|
-
const cacheTtl = {
|
|
26
|
-
session: 3600, // 1 hour
|
|
27
|
-
activeSession: 1800, // 30 minutes
|
|
28
|
-
sessionList: 120, // 2 minutes
|
|
29
|
-
};
|
|
30
22
|
const rawSessionTtl = parseInt(process.env.SESSION_TTL || String(DEFAULT_SESSION_TTL_SECONDS), 10);
|
|
31
23
|
const sessionTtl = Number.isFinite(rawSessionTtl) && rawSessionTtl > 0
|
|
32
24
|
? rawSessionTtl
|
|
33
25
|
: DEFAULT_SESSION_TTL_SECONDS;
|
|
34
26
|
// If no database config, return base config (file-based storage)
|
|
35
|
-
if (!databaseUrl
|
|
36
|
-
return {
|
|
27
|
+
if (!databaseUrl) {
|
|
28
|
+
return { sessionTtl };
|
|
37
29
|
}
|
|
38
|
-
// Validate
|
|
30
|
+
// Validate URL
|
|
39
31
|
try {
|
|
40
32
|
DatabaseUrlSchema.parse(databaseUrl);
|
|
41
|
-
RedisUrlSchema.parse(redisUrl);
|
|
42
33
|
}
|
|
43
34
|
catch (error) {
|
|
44
|
-
throw new Error(`Invalid database
|
|
35
|
+
throw new Error(`Invalid database URL: ${error instanceof Error ? error.message : String(error)}`);
|
|
45
36
|
}
|
|
46
37
|
return {
|
|
47
38
|
database: {
|
|
@@ -53,15 +44,6 @@ export function loadConfig() {
|
|
|
53
44
|
statementTimeout: 10000,
|
|
54
45
|
},
|
|
55
46
|
},
|
|
56
|
-
redis: {
|
|
57
|
-
url: redisUrl,
|
|
58
|
-
retryStrategy: {
|
|
59
|
-
maxRetries: 3,
|
|
60
|
-
initialDelay: 50,
|
|
61
|
-
maxDelay: 2000,
|
|
62
|
-
},
|
|
63
|
-
},
|
|
64
|
-
cacheTtl,
|
|
65
47
|
sessionTtl,
|
|
66
48
|
};
|
|
67
49
|
}
|
package/dist/db.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Pool } from "pg";
|
|
2
|
-
import type { Redis } from "ioredis";
|
|
3
2
|
import { Config } from "./config.js";
|
|
4
3
|
import type { Logger } from "./logger.js";
|
|
5
4
|
export interface HealthCheckResult {
|
|
@@ -7,22 +6,17 @@ export interface HealthCheckResult {
|
|
|
7
6
|
connected: boolean;
|
|
8
7
|
latency: number;
|
|
9
8
|
};
|
|
10
|
-
redis: {
|
|
11
|
-
connected: boolean;
|
|
12
|
-
latency: number;
|
|
13
|
-
};
|
|
14
9
|
}
|
|
15
10
|
/**
|
|
16
|
-
* Database connection manager for PostgreSQL
|
|
11
|
+
* Database connection manager for PostgreSQL-backed sessions.
|
|
17
12
|
*/
|
|
18
13
|
export declare class DatabaseConnection {
|
|
19
14
|
private logger;
|
|
20
15
|
private pool;
|
|
21
|
-
private redis;
|
|
22
16
|
private config;
|
|
23
17
|
constructor(config: Config, logger?: Logger);
|
|
24
18
|
/**
|
|
25
|
-
* Initialize
|
|
19
|
+
* Initialize connection to PostgreSQL.
|
|
26
20
|
*/
|
|
27
21
|
connect(): Promise<void>;
|
|
28
22
|
/**
|
|
@@ -30,17 +24,13 @@ export declare class DatabaseConnection {
|
|
|
30
24
|
*/
|
|
31
25
|
disconnect(): Promise<void>;
|
|
32
26
|
/**
|
|
33
|
-
* Health check for PostgreSQL
|
|
27
|
+
* Health check for PostgreSQL.
|
|
34
28
|
*/
|
|
35
29
|
healthCheck(): Promise<HealthCheckResult>;
|
|
36
30
|
/**
|
|
37
31
|
* Get PostgreSQL pool
|
|
38
32
|
*/
|
|
39
33
|
getPool(): Pool;
|
|
40
|
-
/**
|
|
41
|
-
* Get Redis client
|
|
42
|
-
*/
|
|
43
|
-
getRedis(): Redis;
|
|
44
34
|
}
|
|
45
35
|
/**
|
|
46
36
|
* Factory function to create and connect DatabaseConnection
|
package/dist/db.js
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
import { noopLogger } from "./logger.js";
|
|
2
2
|
/**
|
|
3
|
-
* Database connection manager for PostgreSQL
|
|
3
|
+
* Database connection manager for PostgreSQL-backed sessions.
|
|
4
4
|
*/
|
|
5
5
|
export class DatabaseConnection {
|
|
6
6
|
logger;
|
|
7
7
|
pool = null;
|
|
8
|
-
redis = null;
|
|
9
8
|
config;
|
|
10
9
|
constructor(config, logger = noopLogger) {
|
|
11
10
|
this.logger = logger;
|
|
12
|
-
if (!config.database
|
|
13
|
-
throw new Error("Database
|
|
11
|
+
if (!config.database) {
|
|
12
|
+
throw new Error("Database configuration required");
|
|
14
13
|
}
|
|
15
14
|
this.config = config;
|
|
16
15
|
}
|
|
17
16
|
/**
|
|
18
|
-
* Initialize
|
|
17
|
+
* Initialize connection to PostgreSQL.
|
|
19
18
|
*/
|
|
20
19
|
async connect() {
|
|
21
20
|
const { Pool } = await importOptionalPg();
|
|
22
|
-
const Redis = await importOptionalRedis();
|
|
23
21
|
// Initialize PostgreSQL pool
|
|
24
22
|
const poolConfig = {
|
|
25
23
|
connectionString: this.config.database.connectionString,
|
|
@@ -40,32 +38,6 @@ export class DatabaseConnection {
|
|
|
40
38
|
this.logger.error("Failed to connect to PostgreSQL", { error });
|
|
41
39
|
throw new Error(`Failed to connect to PostgreSQL: ${error instanceof Error ? error.message : String(error)}`);
|
|
42
40
|
}
|
|
43
|
-
// Initialize Redis client
|
|
44
|
-
const redisOptions = {
|
|
45
|
-
retryStrategy: (times) => {
|
|
46
|
-
const { maxRetries, initialDelay, maxDelay } = this.config.redis.retryStrategy;
|
|
47
|
-
if (times > maxRetries) {
|
|
48
|
-
return null; // Stop retrying
|
|
49
|
-
}
|
|
50
|
-
return Math.min(initialDelay * times, maxDelay);
|
|
51
|
-
},
|
|
52
|
-
lazyConnect: false,
|
|
53
|
-
reconnectOnError: (err) => {
|
|
54
|
-
// Reconnect on READONLY and ECONNRESET errors
|
|
55
|
-
const targetErrors = ["READONLY", "ECONNRESET"];
|
|
56
|
-
return targetErrors.some(targetError => err.message.includes(targetError));
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
this.redis = new Redis(this.config.redis.url, redisOptions);
|
|
60
|
-
// Test Redis connection
|
|
61
|
-
try {
|
|
62
|
-
await this.redis.ping();
|
|
63
|
-
this.logger.info("Redis connection established");
|
|
64
|
-
}
|
|
65
|
-
catch (error) {
|
|
66
|
-
this.logger.error("Failed to connect to Redis", { error });
|
|
67
|
-
throw new Error(`Failed to connect to Redis: ${error instanceof Error ? error.message : String(error)}`);
|
|
68
|
-
}
|
|
69
41
|
}
|
|
70
42
|
/**
|
|
71
43
|
* Graceful shutdown - close all connections
|
|
@@ -82,26 +54,16 @@ export class DatabaseConnection {
|
|
|
82
54
|
errors.push(new Error(`PostgreSQL disconnect error: ${error instanceof Error ? error.message : String(error)}`));
|
|
83
55
|
}
|
|
84
56
|
}
|
|
85
|
-
if (this.redis) {
|
|
86
|
-
try {
|
|
87
|
-
this.redis.disconnect();
|
|
88
|
-
this.redis = null;
|
|
89
|
-
}
|
|
90
|
-
catch (error) {
|
|
91
|
-
errors.push(new Error(`Redis disconnect error: ${error instanceof Error ? error.message : String(error)}`));
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
57
|
if (errors.length > 0) {
|
|
95
58
|
throw new Error(`Disconnect errors: ${errors.map(e => e.message).join("; ")}`);
|
|
96
59
|
}
|
|
97
60
|
}
|
|
98
61
|
/**
|
|
99
|
-
* Health check for PostgreSQL
|
|
62
|
+
* Health check for PostgreSQL.
|
|
100
63
|
*/
|
|
101
64
|
async healthCheck() {
|
|
102
65
|
const result = {
|
|
103
66
|
postgres: { connected: false, latency: 0 },
|
|
104
|
-
redis: { connected: false, latency: 0 },
|
|
105
67
|
};
|
|
106
68
|
// Check PostgreSQL
|
|
107
69
|
if (this.pool) {
|
|
@@ -123,21 +85,8 @@ export class DatabaseConnection {
|
|
|
123
85
|
}
|
|
124
86
|
}
|
|
125
87
|
}
|
|
126
|
-
// Check Redis
|
|
127
|
-
if (this.redis) {
|
|
128
|
-
const redisStart = Date.now();
|
|
129
|
-
try {
|
|
130
|
-
await this.redis.ping();
|
|
131
|
-
result.redis.connected = true;
|
|
132
|
-
result.redis.latency = Date.now() - redisStart;
|
|
133
|
-
}
|
|
134
|
-
catch (error) {
|
|
135
|
-
result.redis.connected = false;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
88
|
this.logger.debug("Health check completed", {
|
|
139
89
|
postgres: result.postgres.connected,
|
|
140
|
-
redis: result.redis.connected,
|
|
141
90
|
});
|
|
142
91
|
return result;
|
|
143
92
|
}
|
|
@@ -150,15 +99,6 @@ export class DatabaseConnection {
|
|
|
150
99
|
}
|
|
151
100
|
return this.pool;
|
|
152
101
|
}
|
|
153
|
-
/**
|
|
154
|
-
* Get Redis client
|
|
155
|
-
*/
|
|
156
|
-
getRedis() {
|
|
157
|
-
if (!this.redis) {
|
|
158
|
-
throw new Error("Redis client not initialized");
|
|
159
|
-
}
|
|
160
|
-
return this.redis;
|
|
161
|
-
}
|
|
162
102
|
}
|
|
163
103
|
async function importOptionalPg() {
|
|
164
104
|
try {
|
|
@@ -166,19 +106,7 @@ async function importOptionalPg() {
|
|
|
166
106
|
}
|
|
167
107
|
catch (error) {
|
|
168
108
|
if (error?.code === "ERR_MODULE_NOT_FOUND" || error?.code === "MODULE_NOT_FOUND") {
|
|
169
|
-
throw new Error("PostgreSQL sessions require optional peer dependency 'pg'. Install it alongside llm-cli-gateway to use DATABASE_URL
|
|
170
|
-
}
|
|
171
|
-
throw error;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
async function importOptionalRedis() {
|
|
175
|
-
try {
|
|
176
|
-
const mod = await import("ioredis");
|
|
177
|
-
return mod.Redis ?? mod.default;
|
|
178
|
-
}
|
|
179
|
-
catch (error) {
|
|
180
|
-
if (error?.code === "ERR_MODULE_NOT_FOUND" || error?.code === "MODULE_NOT_FOUND") {
|
|
181
|
-
throw new Error("PostgreSQL sessions require optional peer dependency 'ioredis'. Install it alongside llm-cli-gateway to use DATABASE_URL/REDIS_URL-backed sessions.");
|
|
109
|
+
throw new Error("PostgreSQL sessions require optional peer dependency 'pg'. Install it alongside llm-cli-gateway to use DATABASE_URL-backed sessions.");
|
|
182
110
|
}
|
|
183
111
|
throw error;
|
|
184
112
|
}
|
package/dist/health.d.ts
CHANGED
|
@@ -6,10 +6,6 @@ export interface HealthStatus {
|
|
|
6
6
|
status: "up" | "down";
|
|
7
7
|
latency: number;
|
|
8
8
|
};
|
|
9
|
-
redis: {
|
|
10
|
-
status: "up" | "down";
|
|
11
|
-
latency: number;
|
|
12
|
-
};
|
|
13
9
|
timestamp: string;
|
|
14
10
|
}
|
|
15
11
|
export interface ProviderRuntimeHealth {
|
|
@@ -18,10 +14,7 @@ export interface ProviderRuntimeHealth {
|
|
|
18
14
|
timestamp: string;
|
|
19
15
|
}
|
|
20
16
|
/**
|
|
21
|
-
* Check health status of PostgreSQL
|
|
22
|
-
* - Both up → healthy
|
|
23
|
-
* - Only PostgreSQL up → degraded (Redis down but DB works)
|
|
24
|
-
* - PostgreSQL down → unhealthy (critical failure)
|
|
17
|
+
* Check health status of PostgreSQL.
|
|
25
18
|
*/
|
|
26
19
|
export declare function checkHealth(db: DatabaseConnection): Promise<HealthStatus>;
|
|
27
20
|
export declare function checkProviderRuntimeHealth(): ProviderRuntimeHealth;
|
package/dist/health.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { listProviderRuntimeStatuses } from "./provider-status.js";
|
|
2
2
|
/**
|
|
3
|
-
* Check health status of PostgreSQL
|
|
4
|
-
* - Both up → healthy
|
|
5
|
-
* - Only PostgreSQL up → degraded (Redis down but DB works)
|
|
6
|
-
* - PostgreSQL down → unhealthy (critical failure)
|
|
3
|
+
* Check health status of PostgreSQL.
|
|
7
4
|
*/
|
|
8
5
|
export async function checkHealth(db) {
|
|
9
6
|
const result = await db.healthCheck();
|
|
@@ -13,22 +10,9 @@ export async function checkHealth(db) {
|
|
|
13
10
|
status: result.postgres.connected ? "up" : "down",
|
|
14
11
|
latency: result.postgres.latency,
|
|
15
12
|
},
|
|
16
|
-
redis: {
|
|
17
|
-
status: result.redis.connected ? "up" : "down",
|
|
18
|
-
latency: result.redis.latency,
|
|
19
|
-
},
|
|
20
13
|
timestamp: new Date().toISOString(),
|
|
21
14
|
};
|
|
22
|
-
|
|
23
|
-
if (result.postgres.connected && result.redis.connected) {
|
|
24
|
-
health.status = "healthy";
|
|
25
|
-
}
|
|
26
|
-
else if (result.postgres.connected && !result.redis.connected) {
|
|
27
|
-
health.status = "degraded";
|
|
28
|
-
}
|
|
29
|
-
else {
|
|
30
|
-
health.status = "unhealthy";
|
|
31
|
-
}
|
|
15
|
+
health.status = result.postgres.connected ? "healthy" : "unhealthy";
|
|
32
16
|
return health;
|
|
33
17
|
}
|
|
34
18
|
export function checkProviderRuntimeHealth() {
|
package/dist/index.js
CHANGED
|
@@ -5101,8 +5101,8 @@ async function initializeSessionManager() {
|
|
|
5101
5101
|
// own a single filesystem); revisit if/when worktree support extends
|
|
5102
5102
|
// there.
|
|
5103
5103
|
const worktreeCleanupHook = createWorktreeSessionCleanupHook(logger);
|
|
5104
|
-
if (config.database
|
|
5105
|
-
logger.info("Initializing PostgreSQL
|
|
5104
|
+
if (config.database) {
|
|
5105
|
+
logger.info("Initializing PostgreSQL session manager");
|
|
5106
5106
|
const { createDatabaseConnection } = await import("./db.js");
|
|
5107
5107
|
db = await createDatabaseConnection(config, logger);
|
|
5108
5108
|
sessionManager = await createSessionManager(config, db, logger);
|
package/dist/migrate-sessions.js
CHANGED
|
@@ -82,7 +82,6 @@ Options:
|
|
|
82
82
|
|
|
83
83
|
Environment Variables:
|
|
84
84
|
DATABASE_URL PostgreSQL connection string (required)
|
|
85
|
-
REDIS_URL Redis connection string (required)
|
|
86
85
|
`);
|
|
87
86
|
process.exit(args[0] === "--help" || args[0] === "-h" ? 0 : 1);
|
|
88
87
|
}
|
|
@@ -97,18 +96,17 @@ Environment Variables:
|
|
|
97
96
|
console.error(`Migration Configuration:`);
|
|
98
97
|
console.error(` Source: ${filePath}`);
|
|
99
98
|
console.error(` DATABASE_URL: ${process.env.DATABASE_URL ? "[set]" : "[not set]"}`);
|
|
100
|
-
console.error(` REDIS_URL: ${process.env.REDIS_URL ? "[set]" : "[not set]"}`);
|
|
101
99
|
console.error("");
|
|
102
100
|
// Load config
|
|
103
101
|
const config = loadConfig();
|
|
104
|
-
if (!config.database
|
|
105
|
-
console.error("ERROR: DATABASE_URL
|
|
102
|
+
if (!config.database) {
|
|
103
|
+
console.error("ERROR: DATABASE_URL must be set");
|
|
106
104
|
process.exit(1);
|
|
107
105
|
}
|
|
108
106
|
// Connect to database
|
|
109
107
|
console.error("Connecting to database...");
|
|
110
108
|
const db = await createDatabaseConnection(config, logger);
|
|
111
|
-
const pgManager = new PostgreSQLSessionManager(db.getPool()
|
|
109
|
+
const pgManager = new PostgreSQLSessionManager(db.getPool());
|
|
112
110
|
console.error("✓ Connected to database\n");
|
|
113
111
|
try {
|
|
114
112
|
// Run migration
|
|
@@ -1,77 +1,48 @@
|
|
|
1
1
|
import type { Pool } from "pg";
|
|
2
|
-
import type { Redis } from "ioredis";
|
|
3
2
|
import { Session, CliType } from "./session-manager.js";
|
|
4
|
-
import { CacheTtl } from "./config.js";
|
|
5
|
-
import type { Logger } from "./logger.js";
|
|
6
3
|
export type { Logger } from "./logger.js";
|
|
7
4
|
/**
|
|
8
|
-
* PostgreSQL-backed session manager
|
|
5
|
+
* PostgreSQL-backed session manager. PostgreSQL is the source of truth and
|
|
6
|
+
* the only required service for this backend.
|
|
9
7
|
*/
|
|
10
8
|
export declare class PostgreSQLSessionManager {
|
|
11
9
|
private pool;
|
|
12
|
-
|
|
13
|
-
private cacheTtl;
|
|
14
|
-
private logger;
|
|
15
|
-
constructor(pool: Pool, redis: Redis, cacheTtl: CacheTtl, logger: Logger);
|
|
10
|
+
constructor(pool: Pool);
|
|
16
11
|
/**
|
|
17
|
-
*
|
|
18
|
-
* Returns [success, lockValue] tuple
|
|
19
|
-
*/
|
|
20
|
-
private acquireLock;
|
|
21
|
-
private sleep;
|
|
22
|
-
/**
|
|
23
|
-
* Acquire a distributed lock with bounded retries to smooth contention spikes.
|
|
24
|
-
*/
|
|
25
|
-
private acquireLockWithRetry;
|
|
26
|
-
/**
|
|
27
|
-
* Release distributed lock with optimistic Redis transaction semantics.
|
|
28
|
-
* Only releases if lockValue matches, which prevents releasing another
|
|
29
|
-
* process's lock after expiry/reacquire.
|
|
30
|
-
*/
|
|
31
|
-
private releaseLock;
|
|
32
|
-
/**
|
|
33
|
-
* Invalidate session cache
|
|
34
|
-
*/
|
|
35
|
-
private invalidateCache;
|
|
36
|
-
/**
|
|
37
|
-
* Invalidate session list cache using SCAN (non-blocking)
|
|
38
|
-
*/
|
|
39
|
-
private invalidateListCache;
|
|
40
|
-
/**
|
|
41
|
-
* Create a new session
|
|
12
|
+
* Create a new session.
|
|
42
13
|
*/
|
|
43
14
|
createSession(cli: CliType, description?: string, sessionId?: string): Promise<Session>;
|
|
44
15
|
/**
|
|
45
|
-
* Get session by ID
|
|
16
|
+
* Get session by ID.
|
|
46
17
|
*/
|
|
47
18
|
getSession(sessionId: string): Promise<Session | null>;
|
|
48
19
|
/**
|
|
49
|
-
* List all sessions, optionally filtered by CLI
|
|
20
|
+
* List all sessions, optionally filtered by CLI.
|
|
50
21
|
*/
|
|
51
22
|
listSessions(cli?: CliType): Promise<Session[]>;
|
|
52
23
|
/**
|
|
53
|
-
* Delete a session
|
|
24
|
+
* Delete a session.
|
|
54
25
|
*/
|
|
55
26
|
deleteSession(sessionId: string): Promise<boolean>;
|
|
56
27
|
/**
|
|
57
|
-
* Set active session for a CLI
|
|
28
|
+
* Set active session for a CLI. The row-level update is serialized by
|
|
29
|
+
* PostgreSQL and the session FK keeps stale IDs from being recorded.
|
|
58
30
|
*/
|
|
59
31
|
setActiveSession(cli: CliType, sessionId: string | null): Promise<boolean>;
|
|
60
32
|
/**
|
|
61
|
-
* Get active session for a CLI
|
|
33
|
+
* Get active session for a CLI.
|
|
62
34
|
*/
|
|
63
35
|
getActiveSession(cli: CliType): Promise<Session | null>;
|
|
64
36
|
/**
|
|
65
|
-
* Update session usage timestamp
|
|
37
|
+
* Update session usage timestamp.
|
|
66
38
|
*/
|
|
67
39
|
updateSessionUsage(sessionId: string): Promise<void>;
|
|
68
40
|
/**
|
|
69
|
-
* Update session metadata
|
|
41
|
+
* Update session metadata using PostgreSQL's atomic JSONB merge.
|
|
70
42
|
*/
|
|
71
43
|
updateSessionMetadata(sessionId: string, metadata: Record<string, any>): Promise<boolean>;
|
|
72
44
|
/**
|
|
73
|
-
* Clear all sessions, optionally filtered by CLI
|
|
74
|
-
* Invalidates all related caches (session, active, list)
|
|
45
|
+
* Clear all sessions, optionally filtered by CLI.
|
|
75
46
|
*/
|
|
76
47
|
clearAllSessions(cli?: CliType): Promise<number>;
|
|
77
48
|
}
|
|
@@ -7,112 +7,16 @@ const DEFAULT_SESSION_DESCRIPTIONS = {
|
|
|
7
7
|
mistral: "Mistral Session",
|
|
8
8
|
};
|
|
9
9
|
/**
|
|
10
|
-
* PostgreSQL-backed session manager
|
|
10
|
+
* PostgreSQL-backed session manager. PostgreSQL is the source of truth and
|
|
11
|
+
* the only required service for this backend.
|
|
11
12
|
*/
|
|
12
13
|
export class PostgreSQLSessionManager {
|
|
13
14
|
pool;
|
|
14
|
-
|
|
15
|
-
cacheTtl;
|
|
16
|
-
logger;
|
|
17
|
-
constructor(pool, redis, cacheTtl, logger) {
|
|
15
|
+
constructor(pool) {
|
|
18
16
|
this.pool = pool;
|
|
19
|
-
this.redis = redis;
|
|
20
|
-
this.cacheTtl = cacheTtl;
|
|
21
|
-
this.logger = logger;
|
|
22
17
|
}
|
|
23
18
|
/**
|
|
24
|
-
*
|
|
25
|
-
* Returns [success, lockValue] tuple
|
|
26
|
-
*/
|
|
27
|
-
async acquireLock(key, ttlSeconds) {
|
|
28
|
-
const lockKey = `lock:${key}`;
|
|
29
|
-
const lockValue = randomUUID();
|
|
30
|
-
// SET NX EX atomic operation
|
|
31
|
-
const result = await this.redis.set(lockKey, lockValue, "EX", ttlSeconds, "NX");
|
|
32
|
-
return [result === "OK", lockValue];
|
|
33
|
-
}
|
|
34
|
-
async sleep(ms) {
|
|
35
|
-
await new Promise(resolve => setTimeout(resolve, ms));
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Acquire a distributed lock with bounded retries to smooth contention spikes.
|
|
39
|
-
*/
|
|
40
|
-
async acquireLockWithRetry(key, ttlSeconds, errorLabel, maxWaitMs = 6000) {
|
|
41
|
-
const deadline = Date.now() + maxWaitMs;
|
|
42
|
-
while (true) {
|
|
43
|
-
const [lockAcquired, lockValue] = await this.acquireLock(key, ttlSeconds);
|
|
44
|
-
if (lockAcquired) {
|
|
45
|
-
return lockValue;
|
|
46
|
-
}
|
|
47
|
-
if (Date.now() >= deadline) {
|
|
48
|
-
throw new Error(`Failed to acquire lock for ${errorLabel}`);
|
|
49
|
-
}
|
|
50
|
-
// Small jitter avoids lock-step retries from concurrent callers.
|
|
51
|
-
await this.sleep(25 + Math.floor(Math.random() * 25));
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Release distributed lock with optimistic Redis transaction semantics.
|
|
56
|
-
* Only releases if lockValue matches, which prevents releasing another
|
|
57
|
-
* process's lock after expiry/reacquire.
|
|
58
|
-
*/
|
|
59
|
-
async releaseLock(key, lockValue) {
|
|
60
|
-
const lockKey = `lock:${key}`;
|
|
61
|
-
await this.redis.watch(lockKey);
|
|
62
|
-
try {
|
|
63
|
-
const currentValue = await this.redis.get(lockKey);
|
|
64
|
-
if (currentValue !== lockValue) {
|
|
65
|
-
await this.redis.unwatch();
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
await this.redis.multi().del(lockKey).exec();
|
|
69
|
-
}
|
|
70
|
-
catch (error) {
|
|
71
|
-
await this.redis.unwatch().catch(() => undefined);
|
|
72
|
-
throw error;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Invalidate session cache
|
|
77
|
-
*/
|
|
78
|
-
async invalidateCache(sessionId) {
|
|
79
|
-
try {
|
|
80
|
-
await this.redis.del(`session:${sessionId}`);
|
|
81
|
-
}
|
|
82
|
-
catch (error) {
|
|
83
|
-
// Graceful degradation - log but don't fail
|
|
84
|
-
this.logger.error(`Cache invalidation failed for session ${sessionId}`, { error, sessionId });
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Invalidate session list cache using SCAN (non-blocking)
|
|
89
|
-
*/
|
|
90
|
-
async invalidateListCache(cli) {
|
|
91
|
-
try {
|
|
92
|
-
if (cli) {
|
|
93
|
-
await this.redis.del(`session_list:${cli}`);
|
|
94
|
-
}
|
|
95
|
-
else {
|
|
96
|
-
// Use SCAN instead of KEYS to avoid blocking Redis
|
|
97
|
-
const keys = [];
|
|
98
|
-
let cursor = "0";
|
|
99
|
-
do {
|
|
100
|
-
const [nextCursor, matchedKeys] = await this.redis.scan(cursor, "MATCH", "session_list:*", "COUNT", 100);
|
|
101
|
-
cursor = nextCursor;
|
|
102
|
-
keys.push(...matchedKeys);
|
|
103
|
-
} while (cursor !== "0");
|
|
104
|
-
// Delete in batches to avoid overwhelming Redis
|
|
105
|
-
if (keys.length > 0) {
|
|
106
|
-
await this.redis.del(...keys);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
catch (error) {
|
|
111
|
-
this.logger.error("List cache invalidation failed", { error });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Create a new session
|
|
19
|
+
* Create a new session.
|
|
116
20
|
*/
|
|
117
21
|
async createSession(cli, description, sessionId) {
|
|
118
22
|
const id = sessionId || randomUUID();
|
|
@@ -121,32 +25,19 @@ export class PostgreSQLSessionManager {
|
|
|
121
25
|
const client = await this.pool.connect();
|
|
122
26
|
try {
|
|
123
27
|
await client.query("BEGIN");
|
|
124
|
-
// Insert session
|
|
125
28
|
await client.query(`INSERT INTO sessions (id, cli, description, created_at, last_used_at)
|
|
126
29
|
VALUES ($1, $2, $3, $4, $5)`, [id, cli, sessionDescription, now, now]);
|
|
127
|
-
// Set as active if none exists
|
|
128
30
|
await client.query(`INSERT INTO active_sessions (cli, session_id, updated_at)
|
|
129
31
|
VALUES ($1, $2, $3)
|
|
130
32
|
ON CONFLICT (cli) DO NOTHING`, [cli, id, now]);
|
|
131
33
|
await client.query("COMMIT");
|
|
132
|
-
|
|
34
|
+
return {
|
|
133
35
|
id,
|
|
134
36
|
cli,
|
|
135
37
|
createdAt: now,
|
|
136
38
|
lastUsedAt: now,
|
|
137
39
|
description: sessionDescription,
|
|
138
40
|
};
|
|
139
|
-
// Write-through to cache
|
|
140
|
-
try {
|
|
141
|
-
await this.redis.setex(`session:${id}`, this.cacheTtl.session, JSON.stringify(session));
|
|
142
|
-
}
|
|
143
|
-
catch (error) {
|
|
144
|
-
// Graceful degradation
|
|
145
|
-
this.logger.error("Cache write failed", { error });
|
|
146
|
-
}
|
|
147
|
-
// Invalidate list cache
|
|
148
|
-
await this.invalidateListCache(cli);
|
|
149
|
-
return session;
|
|
150
41
|
}
|
|
151
42
|
catch (error) {
|
|
152
43
|
await client.query("ROLLBACK");
|
|
@@ -157,55 +48,18 @@ export class PostgreSQLSessionManager {
|
|
|
157
48
|
}
|
|
158
49
|
}
|
|
159
50
|
/**
|
|
160
|
-
* Get session by ID
|
|
51
|
+
* Get session by ID.
|
|
161
52
|
*/
|
|
162
53
|
async getSession(sessionId) {
|
|
163
|
-
// Try cache first
|
|
164
|
-
try {
|
|
165
|
-
const cached = await this.redis.get(`session:${sessionId}`);
|
|
166
|
-
if (cached) {
|
|
167
|
-
return JSON.parse(cached);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
catch (error) {
|
|
171
|
-
// Graceful degradation - fallback to DB
|
|
172
|
-
this.logger.error("Cache read failed", { error });
|
|
173
|
-
}
|
|
174
|
-
// Cache miss - query database
|
|
175
54
|
const result = await this.pool.query(`SELECT id, cli, description, metadata, created_at AS "createdAt", last_used_at AS "lastUsedAt"
|
|
176
55
|
FROM sessions
|
|
177
56
|
WHERE id = $1`, [sessionId]);
|
|
178
|
-
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
const session = result.rows[0];
|
|
182
|
-
// Populate cache
|
|
183
|
-
try {
|
|
184
|
-
await this.redis.setex(`session:${sessionId}`, this.cacheTtl.session, JSON.stringify(session));
|
|
185
|
-
}
|
|
186
|
-
catch (error) {
|
|
187
|
-
this.logger.error("Cache write failed", { error });
|
|
188
|
-
}
|
|
189
|
-
return session;
|
|
57
|
+
return result.rows[0] ?? null;
|
|
190
58
|
}
|
|
191
59
|
/**
|
|
192
|
-
* List all sessions, optionally filtered by CLI
|
|
60
|
+
* List all sessions, optionally filtered by CLI.
|
|
193
61
|
*/
|
|
194
62
|
async listSessions(cli) {
|
|
195
|
-
// Try cache for CLI-specific lists
|
|
196
|
-
const cacheKey = cli ? `session_list:${cli}` : null;
|
|
197
|
-
if (cacheKey) {
|
|
198
|
-
try {
|
|
199
|
-
const cached = await this.redis.get(cacheKey);
|
|
200
|
-
if (cached) {
|
|
201
|
-
return JSON.parse(cached);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
catch (error) {
|
|
205
|
-
this.logger.error("Cache read failed", { error });
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
// Query database
|
|
209
63
|
const query = cli
|
|
210
64
|
? `SELECT id, cli, description, metadata, created_at AS "createdAt", last_used_at AS "lastUsedAt"
|
|
211
65
|
FROM sessions
|
|
@@ -217,176 +71,70 @@ export class PostgreSQLSessionManager {
|
|
|
217
71
|
const result = cli
|
|
218
72
|
? await this.pool.query(query, [cli])
|
|
219
73
|
: await this.pool.query(query);
|
|
220
|
-
|
|
221
|
-
// Cache CLI-specific lists
|
|
222
|
-
if (cacheKey) {
|
|
223
|
-
try {
|
|
224
|
-
await this.redis.setex(cacheKey, this.cacheTtl.sessionList, JSON.stringify(sessions));
|
|
225
|
-
}
|
|
226
|
-
catch (error) {
|
|
227
|
-
this.logger.error("Cache write failed", { error });
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
return sessions;
|
|
74
|
+
return result.rows;
|
|
231
75
|
}
|
|
232
76
|
/**
|
|
233
|
-
* Delete a session
|
|
77
|
+
* Delete a session.
|
|
234
78
|
*/
|
|
235
79
|
async deleteSession(sessionId) {
|
|
236
|
-
// Get session to find CLI type
|
|
237
80
|
const session = await this.getSession(sessionId);
|
|
238
81
|
if (!session) {
|
|
239
82
|
return false;
|
|
240
83
|
}
|
|
241
|
-
// Delete from database (CASCADE will handle active_sessions)
|
|
242
84
|
const result = await this.pool.query("DELETE FROM sessions WHERE id = $1", [sessionId]);
|
|
243
|
-
|
|
244
|
-
return false;
|
|
245
|
-
}
|
|
246
|
-
// Invalidate caches (session, active session for this CLI, and list)
|
|
247
|
-
await this.invalidateCache(sessionId);
|
|
248
|
-
try {
|
|
249
|
-
await this.redis.del(`active_session:${session.cli}`);
|
|
250
|
-
}
|
|
251
|
-
catch (error) {
|
|
252
|
-
this.logger.error(`Failed to invalidate active session cache for ${session.cli}`, { error });
|
|
253
|
-
}
|
|
254
|
-
await this.invalidateListCache(session.cli);
|
|
255
|
-
return true;
|
|
85
|
+
return result.rowCount !== 0;
|
|
256
86
|
}
|
|
257
87
|
/**
|
|
258
|
-
* Set active session for a CLI
|
|
88
|
+
* Set active session for a CLI. The row-level update is serialized by
|
|
89
|
+
* PostgreSQL and the session FK keeps stale IDs from being recorded.
|
|
259
90
|
*/
|
|
260
91
|
async setActiveSession(cli, sessionId) {
|
|
261
|
-
// Validate session exists if not null
|
|
262
92
|
if (sessionId !== null) {
|
|
263
93
|
const session = await this.getSession(sessionId);
|
|
264
94
|
if (!session || session.cli !== cli) {
|
|
265
95
|
return false;
|
|
266
96
|
}
|
|
267
97
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
await this.pool.query(`INSERT INTO active_sessions (cli, session_id, updated_at)
|
|
274
|
-
VALUES ($1, $2, $3)
|
|
275
|
-
ON CONFLICT (cli) DO UPDATE SET session_id = $2, updated_at = $3`, [cli, sessionId, now]);
|
|
276
|
-
// Update cache
|
|
277
|
-
try {
|
|
278
|
-
if (sessionId) {
|
|
279
|
-
await this.redis.setex(`active_session:${cli}`, this.cacheTtl.activeSession, sessionId);
|
|
280
|
-
}
|
|
281
|
-
else {
|
|
282
|
-
await this.redis.del(`active_session:${cli}`);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
catch (error) {
|
|
286
|
-
this.logger.error("Cache update failed", { error });
|
|
287
|
-
}
|
|
288
|
-
return true;
|
|
289
|
-
}
|
|
290
|
-
finally {
|
|
291
|
-
// Release lock with ownership verification
|
|
292
|
-
try {
|
|
293
|
-
await this.releaseLock(`active_session:${cli}`, lockValue);
|
|
294
|
-
}
|
|
295
|
-
catch (error) {
|
|
296
|
-
this.logger.error(`Failed to release lock for active session ${cli}`, { error, cli });
|
|
297
|
-
}
|
|
298
|
-
}
|
|
98
|
+
const now = new Date().toISOString();
|
|
99
|
+
await this.pool.query(`INSERT INTO active_sessions (cli, session_id, updated_at)
|
|
100
|
+
VALUES ($1, $2, $3)
|
|
101
|
+
ON CONFLICT (cli) DO UPDATE SET session_id = $2, updated_at = $3`, [cli, sessionId, now]);
|
|
102
|
+
return true;
|
|
299
103
|
}
|
|
300
104
|
/**
|
|
301
|
-
* Get active session for a CLI
|
|
105
|
+
* Get active session for a CLI.
|
|
302
106
|
*/
|
|
303
107
|
async getActiveSession(cli) {
|
|
304
|
-
// Try cache first
|
|
305
|
-
try {
|
|
306
|
-
const cachedId = await this.redis.get(`active_session:${cli}`);
|
|
307
|
-
if (cachedId) {
|
|
308
|
-
return await this.getSession(cachedId);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
catch (error) {
|
|
312
|
-
this.logger.error("Cache read failed", { error });
|
|
313
|
-
}
|
|
314
|
-
// Query database
|
|
315
108
|
const result = await this.pool.query("SELECT session_id FROM active_sessions WHERE cli = $1", [cli]);
|
|
316
|
-
|
|
109
|
+
const sessionId = result.rows[0]?.session_id;
|
|
110
|
+
if (!sessionId) {
|
|
317
111
|
return null;
|
|
318
112
|
}
|
|
319
|
-
const sessionId = result.rows[0].session_id;
|
|
320
|
-
// Populate cache
|
|
321
|
-
try {
|
|
322
|
-
await this.redis.setex(`active_session:${cli}`, this.cacheTtl.activeSession, sessionId);
|
|
323
|
-
}
|
|
324
|
-
catch (error) {
|
|
325
|
-
this.logger.error("Cache write failed", { error });
|
|
326
|
-
}
|
|
327
113
|
return await this.getSession(sessionId);
|
|
328
114
|
}
|
|
329
115
|
/**
|
|
330
|
-
* Update session usage timestamp
|
|
116
|
+
* Update session usage timestamp.
|
|
331
117
|
*/
|
|
332
118
|
async updateSessionUsage(sessionId) {
|
|
333
119
|
const now = new Date().toISOString();
|
|
334
120
|
await this.pool.query("UPDATE sessions SET last_used_at = $1 WHERE id = $2", [now, sessionId]);
|
|
335
|
-
// Invalidate cache to force refresh
|
|
336
|
-
await this.invalidateCache(sessionId);
|
|
337
121
|
}
|
|
338
122
|
/**
|
|
339
|
-
* Update session metadata
|
|
123
|
+
* Update session metadata using PostgreSQL's atomic JSONB merge.
|
|
340
124
|
*/
|
|
341
125
|
async updateSessionMetadata(sessionId, metadata) {
|
|
342
|
-
// Use PostgreSQL JSONB || operator for atomic merge (prevents race conditions)
|
|
343
126
|
const result = await this.pool.query(`UPDATE sessions
|
|
344
127
|
SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb
|
|
345
128
|
WHERE id = $2
|
|
346
129
|
RETURNING id`, [JSON.stringify(metadata), sessionId]);
|
|
347
|
-
|
|
348
|
-
return false;
|
|
349
|
-
}
|
|
350
|
-
// Invalidate cache
|
|
351
|
-
await this.invalidateCache(sessionId);
|
|
352
|
-
return true;
|
|
130
|
+
return result.rowCount !== 0;
|
|
353
131
|
}
|
|
354
132
|
/**
|
|
355
|
-
* Clear all sessions, optionally filtered by CLI
|
|
356
|
-
* Invalidates all related caches (session, active, list)
|
|
133
|
+
* Clear all sessions, optionally filtered by CLI.
|
|
357
134
|
*/
|
|
358
135
|
async clearAllSessions(cli) {
|
|
359
|
-
// First get all sessions to invalidate their caches
|
|
360
|
-
const sessions = await this.listSessions(cli);
|
|
361
|
-
// Delete from database
|
|
362
136
|
const query = cli ? "DELETE FROM sessions WHERE cli = $1" : "DELETE FROM sessions";
|
|
363
137
|
const result = cli ? await this.pool.query(query, [cli]) : await this.pool.query(query);
|
|
364
|
-
// Invalidate individual session caches (concurrent — each has its own try/catch)
|
|
365
|
-
await Promise.all(sessions.map(session => this.invalidateCache(session.id)));
|
|
366
|
-
// Invalidate active session caches
|
|
367
|
-
if (cli) {
|
|
368
|
-
try {
|
|
369
|
-
await this.redis.del(`active_session:${cli}`);
|
|
370
|
-
}
|
|
371
|
-
catch (error) {
|
|
372
|
-
this.logger.error(`Failed to invalidate active session cache for ${cli}`, { error, cli });
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
else {
|
|
376
|
-
// Invalidate all active session caches
|
|
377
|
-
try {
|
|
378
|
-
await Promise.all([
|
|
379
|
-
this.redis.del("active_session:claude"),
|
|
380
|
-
this.redis.del("active_session:codex"),
|
|
381
|
-
this.redis.del("active_session:gemini"),
|
|
382
|
-
]);
|
|
383
|
-
}
|
|
384
|
-
catch (error) {
|
|
385
|
-
this.logger.error("Failed to invalidate active session caches", { error });
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
// Invalidate list caches
|
|
389
|
-
await this.invalidateListCache(cli);
|
|
390
138
|
return result.rowCount || 0;
|
|
391
139
|
}
|
|
392
140
|
}
|
package/dist/session-manager.js
CHANGED
|
@@ -230,15 +230,15 @@ export const SessionManager = FileSessionManager;
|
|
|
230
230
|
* @param logger - Logger instance for structured logging
|
|
231
231
|
*/
|
|
232
232
|
export async function createSessionManager(config, db, logger, opts) {
|
|
233
|
-
if (config?.database
|
|
234
|
-
// Import dynamically to avoid loading pg
|
|
233
|
+
if (config?.database) {
|
|
234
|
+
// Import dynamically to avoid loading pg if not needed.
|
|
235
235
|
const { PostgreSQLSessionManager } = await import("./session-manager-pg.js");
|
|
236
236
|
// Use provided db connection or create new one
|
|
237
237
|
if (!db) {
|
|
238
238
|
const { createDatabaseConnection } = await import("./db.js");
|
|
239
239
|
db = await createDatabaseConnection(config, logger);
|
|
240
240
|
}
|
|
241
|
-
return new PostgreSQLSessionManager(db.getPool()
|
|
241
|
+
return new PostgreSQLSessionManager(db.getPool());
|
|
242
242
|
}
|
|
243
243
|
else {
|
|
244
244
|
// Use file-based storage with TTL from config
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-cli-gateway",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"mcpName": "io.github.verivus-oss/llm-cli-gateway",
|
|
5
5
|
"description": "MCP server providing unified access to Claude Code, Codex, Gemini, Grok, and Mistral Vibe CLIs with session management, retry logic, async job orchestration, durable job results, and cross-LLM validation.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -90,13 +90,9 @@
|
|
|
90
90
|
"zod": "^3.23.0"
|
|
91
91
|
},
|
|
92
92
|
"peerDependencies": {
|
|
93
|
-
"ioredis": "^5.4.1",
|
|
94
93
|
"pg": "^8.12.0"
|
|
95
94
|
},
|
|
96
95
|
"peerDependenciesMeta": {
|
|
97
|
-
"ioredis": {
|
|
98
|
-
"optional": true
|
|
99
|
-
},
|
|
100
96
|
"pg": {
|
|
101
97
|
"optional": true
|
|
102
98
|
}
|
|
@@ -111,7 +107,6 @@
|
|
|
111
107
|
"eslint": "^8.57.1",
|
|
112
108
|
"eslint-config-prettier": "^9.0.0",
|
|
113
109
|
"eslint-plugin-security": "^3.0.1",
|
|
114
|
-
"ioredis": "5.9.2",
|
|
115
110
|
"pg": "^8.12.0",
|
|
116
111
|
"prettier": "^3.0.0",
|
|
117
112
|
"typescript": "^5.0.0",
|