mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 14:16:16 +00:00
Merge pull request #1117 from kaitranntt/kai/feat/1115-session-affinity
feat: add local CLIProxy session affinity controls
This commit is contained in:
21 files changed
+1231
-63
No files matched your search
@@ -151,4 +151,138 @@ describe('cliproxy routing strategy service', () => {
|
||||
expect(methodCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes session-affinity booleans and TTL values', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const mod = await loadRoutingModule();
|
||||
|
||||
expect(mod.normalizeCliproxySessionAffinityEnabled(true)).toBe(true);
|
||||
expect(mod.normalizeCliproxySessionAffinityEnabled('on')).toBe(true);
|
||||
expect(mod.normalizeCliproxySessionAffinityEnabled('false')).toBe(false);
|
||||
expect(mod.normalizeCliproxySessionAffinityEnabled('maybe')).toBeNull();
|
||||
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('1h')).toBe('1h');
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('2h30m')).toBe('2h30m');
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl(' 15m ')).toBe('15m');
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('0s')).toBeNull();
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('tomorrow')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('reads saved local session-affinity settings when live CLIProxy is unavailable', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const { mutateUnifiedConfig } = await import('../../../src/config/unified-config-loader');
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.cliproxy) {
|
||||
config.cliproxy.routing = {
|
||||
strategy: 'round-robin',
|
||||
session_affinity: true,
|
||||
session_affinity_ttl: '2h30m',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const state = await mod.readCliproxySessionAffinityState();
|
||||
|
||||
expect(state.enabled).toBe(true);
|
||||
expect(state.ttl).toBe('2h30m');
|
||||
expect(state.source).toBe('config');
|
||||
expect(state.target).toBe('local');
|
||||
expect(state.manageable).toBe(true);
|
||||
expect(state.reachable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('persists local session-affinity settings even when live CLIProxy is unavailable', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const mod = await loadRoutingModule();
|
||||
const result = await mod.applyCliproxySessionAffinitySettings({
|
||||
enabled: true,
|
||||
ttl: '2h',
|
||||
});
|
||||
|
||||
expect(result.applied).toBe('config-only');
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.ttl).toBe('2h');
|
||||
|
||||
const { loadUnifiedConfig } = await import('../../../src/config/unified-config-loader');
|
||||
const persisted = loadUnifiedConfig();
|
||||
expect(persisted?.cliproxy?.routing?.session_affinity).toBe(true);
|
||||
expect(persisted?.cliproxy?.routing?.session_affinity_ttl).toBe('2h');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not claim live session-affinity application just because local CLIProxy is reachable', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
responseFactory = async () =>
|
||||
new Response(JSON.stringify({ strategy: 'round-robin' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const result = await mod.applyCliproxySessionAffinitySettings({
|
||||
enabled: true,
|
||||
ttl: '30m',
|
||||
});
|
||||
|
||||
expect(result.reachable).toBe(true);
|
||||
expect(result.applied).toBe('config-only');
|
||||
expect(result.message).toContain('does not verify live selector state yet');
|
||||
});
|
||||
});
|
||||
|
||||
it('reports remote session-affinity management as unsupported', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
routingTarget = {
|
||||
host: 'remote.example.com',
|
||||
port: 8080,
|
||||
protocol: 'http',
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
responseFactory = async () =>
|
||||
new Response(JSON.stringify({ strategy: 'round-robin' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const state = await mod.readCliproxySessionAffinityState();
|
||||
|
||||
expect(state.source).toBe('unsupported');
|
||||
expect(state.target).toBe('remote');
|
||||
expect(state.manageable).toBe(false);
|
||||
expect(state.reachable).toBe(true);
|
||||
expect(state.enabled).toBeUndefined();
|
||||
|
||||
const result = await mod.applyCliproxySessionAffinitySettings({
|
||||
enabled: true,
|
||||
ttl: '1h',
|
||||
});
|
||||
|
||||
expect(result.applied).toBe('unsupported');
|
||||
expect(result.manageable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports unsupported remote session-affinity as unreachable when remote routing probe fails', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
routingTarget = {
|
||||
host: 'remote.example.com',
|
||||
port: 8080,
|
||||
protocol: 'http',
|
||||
isRemote: true,
|
||||
};
|
||||
responseFactory = null;
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const state = await mod.readCliproxySessionAffinityState();
|
||||
|
||||
expect(state.source).toBe('unsupported');
|
||||
expect(state.reachable).toBe(false);
|
||||
expect(state.message).toContain('not reachable');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,22 @@ describe('completion backend', () => {
|
||||
expect(values).toContain('my-codex');
|
||||
});
|
||||
|
||||
test('suggests the correct routing affinity completion shape', () => {
|
||||
expect(suggestionValues(['cliproxy', 'routing'])).toEqual(
|
||||
expect.arrayContaining(['set', 'explain', 'affinity'])
|
||||
);
|
||||
expect(suggestionValues(['cliproxy', 'routing', 'affinity'])).toEqual(
|
||||
expect.arrayContaining(['on', 'off'])
|
||||
);
|
||||
expect(suggestionValues(['cliproxy', 'routing', 'affinity', 'on'])).toEqual(
|
||||
expect.arrayContaining(['--ttl'])
|
||||
);
|
||||
expect(suggestionValues(['cliproxy', 'routing', 'affinity', 'off'])).toEqual(
|
||||
expect.arrayContaining(['--ttl'])
|
||||
);
|
||||
expect(suggestionValues(['cliproxy', 'routing', 'affinity', '--ttl'])).not.toContain('--ttl');
|
||||
});
|
||||
|
||||
test('suggests env format values after the format flag', () => {
|
||||
const values = suggestionValues(['env', '--format']);
|
||||
expect(values).toEqual(
|
||||
|
||||
@@ -285,6 +285,74 @@ describe('continuity-inheritance-config', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('cliproxy session-affinity ttl normalization', () => {
|
||||
it('normalizes invalid stored ttl values back to the safe default', () => {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-affinity-ttl-home-'));
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'cliproxy:',
|
||||
' routing:',
|
||||
' strategy: round-robin',
|
||||
' session_affinity: true',
|
||||
' session_affinity_ttl: forever',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
process.env.CCS_HOME = tempHome;
|
||||
try {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
expect(config.cliproxy.routing.session_affinity_ttl).toBe('1h');
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes non-positive stored ttl values back to the safe default', () => {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-affinity-zero-ttl-home-'));
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'cliproxy:',
|
||||
' routing:',
|
||||
' strategy: round-robin',
|
||||
' session_affinity: true',
|
||||
' session_affinity_ttl: 0s',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
process.env.CCS_HOME = tempHome;
|
||||
try {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
expect(config.cliproxy.routing.session_affinity_ttl).toBe('1h');
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('official-channels-config', () => {
|
||||
it('keeps explicit channels.selected empty even when legacy discord_channels.enabled is true', () => {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
|
||||
@@ -7,6 +7,8 @@ describe('cliproxy routing routes', () => {
|
||||
let baseUrl = '';
|
||||
let readStateMock: ReturnType<typeof mock>;
|
||||
let applyStrategyMock: ReturnType<typeof mock>;
|
||||
let readAffinityStateMock: ReturnType<typeof mock>;
|
||||
let applyAffinityMock: ReturnType<typeof mock>;
|
||||
|
||||
beforeEach(async () => {
|
||||
readStateMock = mock(async () => ({
|
||||
@@ -22,16 +24,43 @@ describe('cliproxy routing routes', () => {
|
||||
reachable: true,
|
||||
applied: 'live-and-config',
|
||||
}));
|
||||
readAffinityStateMock = mock(async () => ({
|
||||
enabled: true,
|
||||
ttl: '1h',
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
}));
|
||||
applyAffinityMock = mock(async () => ({
|
||||
enabled: false,
|
||||
ttl: '30m',
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
applied: 'config-only',
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/routing-strategy', () => ({
|
||||
readCliproxyRoutingState: readStateMock,
|
||||
applyCliproxyRoutingStrategy: applyStrategyMock,
|
||||
readCliproxySessionAffinityState: readAffinityStateMock,
|
||||
applyCliproxySessionAffinitySettings: applyAffinityMock,
|
||||
normalizeCliproxyRoutingStrategy: (value: unknown) => {
|
||||
if (value === 'round-robin' || value === 'fill-first') {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
normalizeCliproxySessionAffinityEnabled: (value: unknown) => {
|
||||
if (value === true || value === false) return value;
|
||||
return null;
|
||||
},
|
||||
normalizeCliproxySessionAffinityTtl: (value: unknown) => {
|
||||
if (value === '30m' || value === '1h') return value;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: routingRoutes } = await import(
|
||||
@@ -103,4 +132,52 @@ describe('cliproxy routing routes', () => {
|
||||
applied: 'live-and-config',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the current session-affinity state', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/routing/session-affinity`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
enabled: true,
|
||||
ttl: '1h',
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
});
|
||||
expect(readAffinityStateMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects invalid session-affinity payloads', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/routing/session-affinity`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: 'auto', ttl: 'forever' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Invalid session affinity payload. Use enabled=true|false and ttl like 30m or 1h.',
|
||||
});
|
||||
expect(applyAffinityMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies valid session-affinity settings', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/routing/session-affinity`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: false, ttl: '30m' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(applyAffinityMock).toHaveBeenCalledWith({ enabled: false, ttl: '30m' });
|
||||
expect(await response.json()).toEqual({
|
||||
enabled: false,
|
||||
ttl: '30m',
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
applied: 'config-only',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user