From e6f417c3433ec9aed3784e5c9a28969e8fe1adb2 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 2 May 2026 00:06:26 +0900 Subject: [PATCH] add codex goals settings toggle --- apps/dashboard/src/SettingsPanel.test.ts | 1 + apps/dashboard/src/SettingsPanel.tsx | 103 ++++++++++++++++++++++ apps/dashboard/src/api.ts | 32 +++++++ src/settings-store.test.ts | 45 ++++++++++ src/settings-store.ts | 34 +++++++ src/web-dashboard-settings-routes.test.ts | 22 +++++ src/web-dashboard-settings-routes.ts | 28 ++++++ 7 files changed, 265 insertions(+) create mode 100644 src/settings-store.test.ts diff --git a/apps/dashboard/src/SettingsPanel.test.ts b/apps/dashboard/src/SettingsPanel.test.ts index ec7aa2f..42ec421 100644 --- a/apps/dashboard/src/SettingsPanel.test.ts +++ b/apps/dashboard/src/SettingsPanel.test.ts @@ -34,6 +34,7 @@ describe('SettingsPanel', () => { expect(html).toContain('모델'); expect(html).toContain('MoA 참조 모델'); expect(html).toContain('패스트 모드'); + expect(html).toContain('Codex 실험 기능'); expect(html).toContain('불러오는 중'); expect(html).toContain('Claude'); expect(html).toContain('계정'); diff --git a/apps/dashboard/src/SettingsPanel.tsx b/apps/dashboard/src/SettingsPanel.tsx index 978039c..eed5ddb 100644 --- a/apps/dashboard/src/SettingsPanel.tsx +++ b/apps/dashboard/src/SettingsPanel.tsx @@ -3,17 +3,20 @@ import { useEffect, useState } from 'react'; import { type ClaudeAccountSummary, type CodexAccountSummary, + type CodexFeatureSnapshot, type FastModeSnapshot, type ModelConfigSnapshot, type ModelRoleConfig, addClaudeAccount, deleteAccount, fetchAccounts, + fetchCodexFeatures, fetchFastMode, fetchModelConfig, refreshAllCodexAccounts as refreshAllCodexAccountsApi, refreshCodexAccount as refreshCodexAccountApi, setCurrentCodexAccount as setCurrentCodexAccountApi, + updateCodexFeatures, updateFastMode, updateModels, } from './api'; @@ -82,6 +85,8 @@ export function SettingsPanel({ + + ); @@ -302,6 +307,104 @@ function FastModeSettings() { ); } +function CodexFeatureSettings({ + onRestartStack, +}: { + onRestartStack: () => void; +}) { + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [savedAt, setSavedAt] = useState(null); + + useEffect(() => { + let cancelled = false; + fetchCodexFeatures() + .then((snapshot) => { + if (cancelled) return; + setState(snapshot); + setError(null); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + }); + return () => { + cancelled = true; + }; + }, []); + + async function toggleGoals() { + if (!state) return; + const previous = state; + const optimistic = { ...state, goals: !state.goals }; + setState(optimistic); + setBusy(true); + setError(null); + try { + const fresh = await updateCodexFeatures({ goals: optimistic.goals }); + setState(fresh); + setSavedAt(Date.now()); + } catch (err) { + setState(previous); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( +
+

Codex 실험 기능

+ {error ?

{error}

: null} + {!state ? ( +

불러오는 중…

+ ) : ( + <> + +
+ {savedAt ? ( + + 저장됨. 적용하려면 스택 재시작 필요. + + ) : null} + +
+ + )} +
+ ); +} + function formatExpiry( iso: string | null, ): { label: string; cls: string } | null { diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index b317573..baf96e6 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -388,6 +388,10 @@ export interface FastModeSnapshot { claude: boolean; } +export interface CodexFeatureSnapshot { + goals: boolean; +} + export interface MoaReferenceStatus { model: string; checkedAt: string; @@ -538,6 +542,34 @@ export async function updateFastMode( return (await response.json()) as FastModeSnapshot; } +export async function fetchCodexFeatures(): Promise { + return fetchJson('/api/settings/codex-features'); +} + +export async function updateCodexFeatures( + input: Partial, +): Promise { + const response = await fetch('/api/settings/codex-features', { + method: 'PUT', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(input), + }); + if (!response.ok) { + let msg = `update codex features failed: ${response.status}`; + try { + const payload = (await response.json()) as { error?: string }; + if (payload.error) msg = payload.error; + } catch { + /* ignore */ + } + throw new Error(msg); + } + return (await response.json()) as CodexFeatureSnapshot; +} + export async function fetchMoaSettings(): Promise { return fetchJson('/api/settings/moa'); } diff --git a/src/settings-store.test.ts b/src/settings-store.test.ts new file mode 100644 index 0000000..2f965e9 --- /dev/null +++ b/src/settings-store.test.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { getCodexFeatures, updateCodexFeatures } from './settings-store.js'; + +describe('settings-store Codex features', () => { + let tempDir: string; + let previousCwd: string; + let previousCodexGoals: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-settings-')); + previousCwd = process.cwd(); + previousCodexGoals = process.env.CODEX_GOALS; + delete process.env.CODEX_GOALS; + process.chdir(tempDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + if (previousCodexGoals === undefined) { + delete process.env.CODEX_GOALS; + } else { + process.env.CODEX_GOALS = previousCodexGoals; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('stores the Codex goals opt-in in the EJClaw .env file', () => { + expect(getCodexFeatures()).toEqual({ goals: false }); + + expect(updateCodexFeatures({ goals: true })).toEqual({ goals: true }); + expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).toContain( + 'CODEX_GOALS=true', + ); + + expect(updateCodexFeatures({ goals: false })).toEqual({ goals: false }); + expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).toContain( + 'CODEX_GOALS=false', + ); + }); +}); diff --git a/src/settings-store.ts b/src/settings-store.ts index e861cfa..3ecd232 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -56,6 +56,10 @@ export interface FastModeSnapshot { claude: boolean; } +export interface CodexFeatureSnapshot { + goals: boolean; +} + const ROLE_KEYS = ['OWNER', 'REVIEWER', 'ARBITER'] as const; function envFilePath(): string { @@ -634,6 +638,36 @@ export function updateFastMode( return getFastMode(); } +function readCodexGoals(): boolean { + return readEnvOrProcess('CODEX_GOALS') === 'true'; +} + +function writeCodexGoals(value: boolean): void { + const file = envFilePath(); + const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''; + const updated = setOrInsertEnvLine( + content, + 'CODEX_GOALS', + value ? 'true' : 'false', + ); + const tempPath = `${file}.tmp`; + fs.writeFileSync(tempPath, updated, { mode: 0o600 }); + fs.renameSync(tempPath, file); +} + +export function getCodexFeatures(): CodexFeatureSnapshot { + return { + goals: readCodexGoals(), + }; +} + +export function updateCodexFeatures( + input: Partial, +): CodexFeatureSnapshot { + if (typeof input.goals === 'boolean') writeCodexGoals(input.goals); + return getCodexFeatures(); +} + export function removeAccountDirectory( provider: 'claude' | 'codex', index: number, diff --git a/src/web-dashboard-settings-routes.test.ts b/src/web-dashboard-settings-routes.test.ts index 3b29fc2..84ee2af 100644 --- a/src/web-dashboard-settings-routes.test.ts +++ b/src/web-dashboard-settings-routes.test.ts @@ -7,6 +7,7 @@ import { import type { ClaudeAccountSummary, CodexAccountSummary, + CodexFeatureSnapshot, FastModeSnapshot, ModelConfigSnapshot, } from './settings-store.js'; @@ -42,6 +43,7 @@ const modelConfig: ModelConfigSnapshot = { }; const fastMode: FastModeSnapshot = { codex: true, claude: false }; +const codexFeatures: CodexFeatureSnapshot = { goals: false }; const moaSettings: MoaSettingsSnapshot = { enabled: true, @@ -107,6 +109,7 @@ function makeDeps( responseLength: 2, }), getActiveCodexSettingsIndex: () => 1, + getCodexFeatures: () => codexFeatures, getFastMode: () => fastMode, getModelConfig: () => modelConfig, getMoaSettings: () => moaSettings, @@ -116,6 +119,7 @@ function makeDeps( refreshCodexAccount: async () => makeCodexAccount(), removeAccountDirectory: () => undefined, setActiveCodexSettingsIndex: () => undefined, + updateCodexFeatures: () => codexFeatures, updateFastMode: () => fastMode, updateModelConfig: () => modelConfig, updateMoaSettings: () => moaSettings, @@ -155,6 +159,10 @@ describe('web dashboard settings routes', () => { expect(mode?.status).toBe(200); await expect(mode?.json()).resolves.toEqual(fastMode); + const features = await route('/api/settings/codex-features'); + expect(features?.status).toBe(200); + await expect(features?.json()).resolves.toEqual(codexFeatures); + const moa = await route('/api/settings/moa'); expect(moa?.status).toBe(200); const moaJson = (await moa?.json()) as MoaSettingsSnapshot; @@ -187,6 +195,10 @@ describe('web dashboard settings routes', () => { calls.push(`models:${JSON.stringify(input)}`); return modelConfig; }, + updateCodexFeatures: (input) => { + calls.push(`codex-features:${JSON.stringify(input)}`); + return { goals: input.goals === true }; + }, updateMoaSettings: (input) => { calls.push(`moa:${JSON.stringify(input)}`); return moaSettings; @@ -230,6 +242,15 @@ describe('web dashboard settings routes', () => { codexCurrentIndex: 1, }); + const goals = await route( + '/api/settings/codex-features', + 'PATCH', + { goals: true }, + deps, + ); + expect(goals?.status).toBe(200); + await expect(goals?.json()).resolves.toEqual({ goals: true }); + const moa = await route( '/api/settings/moa', 'PATCH', @@ -255,6 +276,7 @@ describe('web dashboard settings routes', () => { 'add:claude-token', 'delete:codex:4', 'current:4', + 'codex-features:{"goals":true}', 'moa:{"enabled":false,"models":[{"name":"kimi","enabled":false}]}', ]); }); diff --git a/src/web-dashboard-settings-routes.ts b/src/web-dashboard-settings-routes.ts index b0f268a..fcec7fe 100644 --- a/src/web-dashboard-settings-routes.ts +++ b/src/web-dashboard-settings-routes.ts @@ -1,6 +1,7 @@ import { addClaudeAccountFromToken, getActiveCodexSettingsIndex, + getCodexFeatures, getFastMode, getModelConfig, listClaudeAccounts, @@ -9,9 +10,11 @@ import { refreshCodexAccount, removeAccountDirectory, setActiveCodexSettingsIndex, + updateCodexFeatures, updateFastMode, updateModelConfig, type ClaudeAccountSummary, + type CodexFeatureSnapshot, type CodexAccountSummary, type FastModeSnapshot, type ModelConfigSnapshot, @@ -33,6 +36,7 @@ export interface SettingsRouteDependencies { addClaudeAccountFromToken: typeof addClaudeAccountFromToken; checkMoaModel: typeof checkMoaModel; getActiveCodexSettingsIndex: typeof getActiveCodexSettingsIndex; + getCodexFeatures: typeof getCodexFeatures; getFastMode: typeof getFastMode; getModelConfig: typeof getModelConfig; getMoaSettings: typeof getMoaSettings; @@ -42,6 +46,7 @@ export interface SettingsRouteDependencies { refreshCodexAccount: typeof refreshCodexAccount; removeAccountDirectory: typeof removeAccountDirectory; setActiveCodexSettingsIndex: typeof setActiveCodexSettingsIndex; + updateCodexFeatures: typeof updateCodexFeatures; updateFastMode: typeof updateFastMode; updateModelConfig: typeof updateModelConfig; updateMoaSettings: typeof updateMoaSettings; @@ -58,6 +63,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = { addClaudeAccountFromToken, checkMoaModel, getActiveCodexSettingsIndex, + getCodexFeatures, getFastMode, getModelConfig, getMoaSettings, @@ -67,6 +73,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = { refreshCodexAccount, removeAccountDirectory, setActiveCodexSettingsIndex, + updateCodexFeatures, updateFastMode, updateModelConfig, updateMoaSettings, @@ -133,6 +140,23 @@ async function handleFastModeRoute( } } +async function handleCodexFeaturesRoute( + request: Request, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Promise { + if (readMethod(request.method)) return jsonResponse(deps.getCodexFeatures()); + if (request.method !== 'PUT' && request.method !== 'PATCH') return null; + + const body = await readJsonObject(request, jsonResponse); + if (body instanceof Response) return body; + try { + return jsonResponse(deps.updateCodexFeatures(body)); + } catch (err) { + return jsonResponse({ error: errorMessage(err) }, { status: 500 }); + } +} + async function handleMoaSettingsRoute( request: Request, jsonResponse: JsonResponse, @@ -299,6 +323,9 @@ export async function handleSettingsRoute({ if (url.pathname === '/api/settings/fast-mode') { return handleFastModeRoute(request, jsonResponse, deps); } + if (url.pathname === '/api/settings/codex-features') { + return handleCodexFeaturesRoute(request, jsonResponse, deps); + } if (url.pathname === '/api/settings/moa') { return handleMoaSettingsRoute(request, jsonResponse, deps); } @@ -335,6 +362,7 @@ export async function handleSettingsRoute({ export type { ClaudeAccountSummary, CodexAccountSummary, + CodexFeatureSnapshot, FastModeSnapshot, ModelConfigSnapshot, MoaSettingsSnapshot,